Open a pull request
Compare changes across branches, commits, tags, and more below. If you need to, you can also compare across forks.

...
✔Able to merge. These branches can be automatically merged.
Styling with Markdown is supported
Commits not after 2026-05-24
Showing 67 changed files with 12202 additions and 1333 deletions
+32 -0
AGENTS.md
@@ -0,0 +1,32 @@
1
+# Agent Guide
2
+
3
+This project has dedicated specifications to keep implementation work aligned and to prevent agent drift.
4
+
5
+Before changing code, read the documentation that matches the touched area:
6
+
7
+- `Documentation/API Reference/README.md`
8
+  Entry point for entity, operation, invariant, and test expectations.
9
+- `Documentation/API Reference/UI_ALIGNMENT.md`
10
+  UI-to-spec mapping and known alignment gaps.
11
+- `Documentation/Project Structure and Naming.md`
12
+  Folder, naming, and view-organization rules.
13
+- `Documentation/Research Resources/README.md`
14
+  Vendor manuals, protocol notes, and reverse-engineering material.
15
+
16
+## Working Rules
17
+
18
+- Treat `MUST` items in `Documentation/API Reference/` as required behavior.
19
+- Treat `SHOULD` items as expected behavior; regressions need a reason and documentation update.
20
+- If code and documentation disagree, do not silently choose one. Update the implementation, update the spec, or call out the conflict.
21
+- Keep feature work scoped to the relevant model, view, or operation described in the docs.
22
+- When adding behavior, update the matching API Reference page if the behavior changes an invariant, public API, storage shape, sync behavior, or user-visible workflow.
23
+- Prefer existing project patterns over new abstractions unless the docs or surrounding code clearly support the change.
24
+
25
+## Quick Routing
26
+
27
+- Meter Bluetooth, connection, measurements: `Documentation/API Reference/Meter.md`, `BluetoothDiscovery.md`, `Operations.md`
28
+- Charge sessions and charged devices: `ChargedDevice.md`, `ChargingMonitoring.md`, `ChargeCurveIsolation.md`, `ChargeCurveStorage.md`
29
+- Capacity and consumption logic: `CapacityMeasurement.md`, `ConsumptionMeasurement.md`, `IdleConsumptionMeasurement.md`
30
+- Powerbank behavior: `Powerbank.md`
31
+- CloudKit and cross-device data integrity: `CloudKitSync.md`, `Charge Session Integrity and Conflict Healing.md`
32
+- UI changes: `UI_ALIGNMENT.md` and the relevant operation/entity page
+328 -0
Documentation/API Reference/BluetoothDiscovery.md
@@ -0,0 +1,328 @@
1
+# Bluetooth Discovery
2
+
3
+Mecanismul de descoperire şi conectare a dispozitivelor Bluetooth.
4
+
5
+## Arhitectură
6
+
7
+### Components
8
+
9
+1. **BluetoothManager**: coordonează CBCentralManager şi descoperirile
10
+2. **BluetoothRadio**: interfață spre Core Bluetooth low-level
11
+3. **BluetoothSerial**: comunicare pe caracteristici UART
12
+4. **MeterCapabilities**: detectează tip meter (UM25C, UM34C, TC66C)
13
+
14
+## Scanning
15
+
16
+### Lifecycle
17
+
18
+```
19
+User opens app
20
+  ↓
21
+SceneDelegate calls appData.activateCloudDeviceSync()
22
+  ↓
23
+BluetoothManager starts CBCentralManager
24
+  ↓
25
+CBCentralManager begins scanning
26
+  ↓
27
+didDiscoverPeripheral → filter by service UUIDs
28
+  ↓
29
+Check device profile (class, capabilities)
30
+  ↓
31
+Add to discoveredMeters list (UI refresh)
32
+```
33
+
34
+### Service UUIDs
35
+
36
+```swift
37
+let targetServices = [
38
+    CBUUID(string: "FFF0"),    // UM25C, UM34C
39
+    CBUUID(string: "180D"),    // TC66C (generic service)
40
+]
41
+```
42
+
43
+- **MUST**: Scan cu service UUIDs specifice (nu scan generic)
44
+- **SHOULD**: Filter prin manufacturer data dacă posibil
45
+- **REASON**: Reduce energy drain, reduce noise
46
+
47
+### Advertisement parsing
48
+
49
+Meter advertise-ază:
50
+- Device name: de ex "UM25C-XXXX" sau "TC66-XXXX"
51
+- Services: FFF0 (UM series) sau 180D (TC66)
52
+- Manufacturer data: RDTech identifier
53
+
54
+```swift
55
+if let manufacturerData = advertisement[CBAdvertisementDataManufacturerDataKey] as? Data {
56
+    let manufacturerId = manufacturerData.withUnsafeBytes { $0.load(as: UInt16.self) }
57
+    if manufacturerId == 0x5449 { // RDTech
58
+        // Possible UM meter
59
+    }
60
+}
61
+```
62
+
63
+- **SHOULD**: Parse manufacturer data pentru identificare rapidă
64
+- **MAY**: Use device name ca fallback (less reliable)
65
+
66
+### Device identification
67
+
68
+```swift
69
+func identifyMeterType(name: String, services: [CBUUID]) -> Model {
70
+    if name.contains("UM25C") || services.contains(FFF0) {
71
+        return .UM25C
72
+    } else if name.contains("UM34C") || services.contains(FFF0) {
73
+        return .UM34C
74
+    } else if name.contains("TC66") || services.contains(180D) {
75
+        return .TC66C
76
+    }
77
+    return .unknown
78
+}
79
+```
80
+
81
+- **MUST**: Trebuie să identific corect tipul de meter
82
+- **SHOULD**: Use name + services (redundant checks)
83
+- **MUST**: Fallback la `.unknown` dacă uncertain
84
+
85
+## Connection
86
+
87
+### Initiation
88
+
89
+```
90
+User taps meter in list
91
+  ↓
92
+AppData calls meter.connect()
93
+  ↓
94
+BluetoothManager calls cbCentralManager.connect(peripheral)
95
+  ↓
96
+didConnect: state → peripheralConnected
97
+  ↓
98
+Discover services & characteristics
99
+```
100
+
101
+### Service/Characteristic discovery
102
+
103
+```swift
104
+// For UM series (FFF0)
105
+let targetService = CBUUID(string: "FFF0")
106
+let readCharacteristic = CBUUID(string: "FFF1")  // read measurements
107
+let writeCharacteristic = CBUUID(string: "FFF2") // send commands
108
+
109
+// For TC66 (180D)
110
+let heartRate = CBUUID(string: "2A37")  // uses standard HRM characteristic
111
+```
112
+
113
+- **MUST**: Discover services → discover characteristics (ordered)
114
+- **MUST**: Find expected characteristics, fail if not found
115
+- **SHOULD**: Subscribe to notifications pentru updates
116
+- **TIMEOUT**: 5s max pentru service discovery
117
+
118
+### State transitions
119
+
120
+```
121
+peripheralConnected
122
+  ↓
123
+discoveringServices (discovering FFF0 / 180D)
124
+  ↓
125
+discoveringCharacteristics (discovering FFF1, FFF2 / 2A37)
126
+  ↓
127
+peripheralReady (services + characteristics found)
128
+  ↓
129
+comunicating ↔ dataIsAvailable (steady state)
130
+```
131
+
132
+- **MUST**: Stare monotonă crescătoare (no rollback)
133
+- **SHOULD**: Log state transitions
134
+- **MUST**: Fail gracefully dacă characteristics nu sunt găsite
135
+
136
+## Communication
137
+
138
+### Measurement requests
139
+
140
+**UM series** (UM25C, UM34C):
141
+```swift
142
+// Send command to FFF2 (write characteristic)
143
+let command = UMProtocol.buildMeasurementRequest()
144
+peripheral.writeValue(command, for: writeCharacteristic, type: .withResponse)
145
+
146
+// Receive on FFF1 (read characteristic)
147
+// Parse payload (voltage, current, power, temperature, etc.)
148
+let measurement = UMProtocol.parseMeasurement(data)
149
+```
150
+
151
+**TC66C**:
152
+```swift
153
+// Uses standard HRM (Heart Rate Measurement) characteristic
154
+// Value format: flags byte + heart_rate_value (2 bytes)
155
+// Repurposed for power data: MSB = watts, LSB = amps (approximation)
156
+let measurement = TC66Protocol.parseMeasurement(data)
157
+```
158
+
159
+- **MUST**: Parse protocol-specific payloads
160
+- **MUST**: Validate checksum (if applicable)
161
+- **SHOULD**: Handle invalid/truncated payloads gracefully
162
+- **TIMEOUT**: 3s per measurement request
163
+
164
+### Write commands
165
+
166
+UM series supports commands:
167
+- Request measurement: `0x00 0xF0 0xA0 0x1B` (+ checksum)
168
+- Set time: `0x02 ...` (timestamp)
169
+- Set calibration: (advanced)
170
+
171
+```swift
172
+peripheral.writeValue(
173
+    command,
174
+    for: writeCharacteristic,
175
+    type: .withResponse  // MUST: wait for ACK
176
+)
177
+```
178
+
179
+- **MUST**: Use `.withResponse` pentru command-uri critice
180
+- **MAY**: Use `.withoutResponse` pentru bulk writes
181
+- **SHOULD**: Validate response ACK
182
+
183
+## Disconnection handling
184
+
185
+### Intentional disconnect
186
+
187
+```
188
+User taps "Disconnect"
189
+  ↓
190
+meter.disconnect()
191
+  ↓
192
+BluetoothManager calls cbCentralManager.cancelPeripheralConnection()
193
+  ↓
194
+didDisconnect: state → peripheralNotConnected
195
+```
196
+
197
+- **MUST**: Anulează pending operations
198
+- **MUST**: Anulează auto-reconnect logic
199
+- **MUST**: Eliberează callbacks
200
+
201
+### Unintentional disconnect (BT drop)
202
+
203
+```
204
+BT device disconnects (out of range / powered off / interference)
205
+  ↓
206
+didDisconnect event (didDisconnect reason: optional)
207
+  ↓
208
+state → peripheralNotConnected
209
+  ↓
210
+Auto-reconnect logic starts (with backoff)
211
+```
212
+
213
+- **MUST**: Detecta unintentional drops (log reason dacă available)
214
+- **SHOULD**: Incepe auto-reconnect cu backoff exponential
215
+- **MUST**: Anulează dacă user disconnect manual (flag)
216
+
217
+## Auto-reconnect
218
+
219
+### Backoff strategy
220
+
221
+```
222
+Attempt 1: 1s delay
223
+Attempt 2: 2s delay
224
+Attempt 3: 4s delay
225
+Attempt 4: 8s delay
226
+Attempt 5: 16s delay
227
+Attempt 6: 32s delay
228
+Attempt 7+: 60s delay (capped)
229
+Max attempts: 3
230
+```
231
+
232
+- **MUST**: Exponential backoff (2^n, capped at 60s)
233
+- **MUST**: Max 3 consecutive retry-uri
234
+- **MUST**: Stop retry dacă user disconnect manual
235
+- **SHOULD**: Log fiecare retry attempt
236
+
237
+### Trigger conditions
238
+
239
+- **MUST**: Activate automatic reconnect doar dacă user conectase anterior
240
+- **MUST**: Disable dacă user disconnect manual
241
+- **MUST**: Disable dacă app goes background > 10 min
242
+- **SHOULD**: Resume reconnect dacă app returns foreground
243
+
244
+## Testing
245
+
246
+### Unit tests
247
+
248
+```swift
249
+test_scanFiltersByServiceUUIDs()
250
+test_deviceIdentification_UM25C()
251
+test_deviceIdentification_UM34C()
252
+test_deviceIdentification_TC66C()
253
+test_connectionStateTransitions()
254
+test_serviceDiscovery_UM25C()
255
+test_characteristicDiscovery_UM25C()
256
+test_measurementParsing_ValidPayload()
257
+test_measurementParsing_InvalidPayload()
258
+test_disconnectCleansUp()
259
+test_unintentionalDropDetected()
260
+test_autoReconnectBackoff_Exponential()
261
+test_autoReconnectStops_OnManualDisconnect()
262
+```
263
+
264
+### Integration tests
265
+
266
+- [ ] Scan detects available meters
267
+- [ ] Device type identified correctly
268
+- [ ] Connect → service discover → ready (full flow)
269
+- [ ] Measurement received and parsed
270
+- [ ] Unintentional drop detected + reconnect
271
+- [ ] Auto-reconnect respects backoff timing
272
+- [ ] Manual disconnect stops auto-reconnect
273
+
274
+## Error handling
275
+
276
+### Scan errors
277
+
278
+```
279
+Error: CBError.unknown
280
+Error: CBError.managerStatePoweredOff
281
+Error: CBError.invalidParameters
282
+```
283
+
284
+Handling:
285
+- Retry scan periodically
286
+- Notify UI: "Bluetooth unavailable"
287
+
288
+### Connection errors
289
+
290
+```
291
+Error: peripheral not found
292
+→ Retry with backoff
293
+
294
+Error: timeout (no services found)
295
+→ Disconnect + retry
296
+
297
+Error: security/pairing required
298
+→ Notify user: "Pair device in Settings"
299
+```
300
+
301
+### Communication errors
302
+
303
+```
304
+Error: write failed
305
+→ Retry measurement request
306
+
307
+Error: invalid payload
308
+→ Log error, skip measurement
309
+
310
+Error: characteristic not found
311
+→ Disconnect + mark incompatible
312
+```
313
+
314
+## Dependencies
315
+
316
+- `CoreBluetooth`: CBCentralManager, CBPeripheral
317
+- `UMProtocol`: payload parsing for UM series
318
+- `TC66Protocol`: payload parsing for TC66C
319
+- `MeterCapabilities`: device type detection
320
+- `AppData`: orchestration
321
+
322
+## References
323
+
324
+- [UMProtocol.swift](../../USB%20Meter/Model/UMProtocol.swift)
325
+- [TC66Protocol.swift](../../USB%20Meter/Model/TC66Protocol.swift)
326
+- [BluetoothManager.swift](../../USB%20Meter/Model/BluetoothManager.swift)
327
+- [External documentation](https://sigrok.org/wiki/RDTech_UM_series)
328
+- [TC66C reverse-engineering notes](../Research%20Resources/Payload%20Notes/TC66C%20Transport%20and%20Payload%20Working%20Note.md)
+301 -0
Documentation/API Reference/CapacityMeasurement.md
@@ -0,0 +1,301 @@
1
+# Capacity Measurement Operation
2
+
3
+Măsurarea şi calculul capacităţii unei baterii din sesiunile de încărcare.
4
+
5
+## Responsabilități
6
+
7
+- Determinarea capacităţii reale a unei baterii (mAh)
8
+- Validarea completitudinii datelor (0% → 100%)
9
+- Calculul din energie + voltaje nominal
10
+- Tracking capacity degradation (baterie veche)
11
+
12
+## Concepte
13
+
14
+### Capacity vs Energy
15
+
16
+- **Capacity (mAh)**: Cantitate de sarcină. Ex: 3000 mAh
17
+- **Energy (Wh)**: Capacitate × voltaj nominal. Ex: 3000mAh × 3.7V = 11.1Wh
18
+
19
+Formula:
20
+```
21
+Capacity (mAh) = Energy (Wh) × 1000 / Nominal Voltage (V)
22
+```
23
+
24
+Exemplu:
25
+```
26
+Energy = 11.1 Wh
27
+Nominal voltage = 3.7V (typical Li-ion)
28
+Capacity = 11.1 × 1000 / 3.7 = 3000 mAh
29
+```
30
+
31
+## Invarianţi
32
+
33
+- **MUST**: Capacity > 0 mAh (nu poate fi 0 sau negativ)
34
+- **MUST**: Capacity ≤ 10000 mAh pentru device-uri obişnuite
35
+- **MUST**: Sesiune validă pentru capacity learning trebuie: battery 0% → 100%
36
+- **MUST**: Nominal voltage trebuie specificat (nu se calculează)
37
+- **MUST**: Capacity learning doar din **complete discharge cycles**
38
+- **SHOULD**: Capacity măsurat ≥ 80% din rated capacity (bateria e "OK")
39
+- **MAY**: Capacity < 80% = aged battery, notify user
40
+
41
+## Types of sessions
42
+
43
+### Full discharge (valid pentru learning)
44
+
45
+```
46
+Battery: 100% → ... → 0%
47
+Device: ON throughout
48
+Meter: record-ează energie totală
49
+
50
+⟹ Capacity = Energy / Nominal_Voltage
51
+```
52
+
53
+**Invarianţi:**
54
+- **MUST**: Start @ ~100%, end @ ~0%
55
+- **MUST**: Fără interruption (meter disconnect = invalid)
56
+- **MUST**: Device rămâne ON (nu suspend mid-session)
57
+
58
+### Partial charge
59
+
60
+```
61
+Battery: 20% → 50%
62
+Device: OFF (charging)
63
+
64
+⟹ Cannot infer capacity (unknown starting level)
65
+```
66
+
67
+**MUST**: Ignore pentru capacity learning
68
+
69
+### Trickle charge (edge case)
70
+
71
+```
72
+Battery: 95% → 100%
73
+Current: <50mA (charging optimizer active)
74
+Duration: >2 hours
75
+
76
+⟹ Energy negligible, skip
77
+```
78
+
79
+## Algorithm
80
+
81
+### Capacity learning from discharge
82
+
83
+```swift
84
+func learnCapacityFromDischarge(session: ChargeRecord) -> Double? {
85
+    // 1. Validate session
86
+    guard isCompleteDischargeSession(session) else { return nil }
87
+    
88
+    // 2. Extract energy
89
+    let energyWh = session.totalEnergy
90
+    
91
+    // 3. Get nominal voltage
92
+    let nominalVoltageV = device.nominalBatteryVoltage // 3.7, 5.0, etc
93
+    
94
+    // 4. Calculate capacity
95
+    let capacityMah = energyWh * 1000 / nominalVoltageV
96
+    
97
+    // 5. Validate result
98
+    guard capacityMah > 0 && capacityMah <= 10000 else {
99
+        logWarning("Capacity outside bounds: \(capacityMah) mAh")
100
+        return nil
101
+    }
102
+    
103
+    return capacityMah
104
+}
105
+
106
+func isCompleteDischargeSession(_ session: ChargeRecord) -> Bool {
107
+    // Check battery levels
108
+    guard let startBattery = session.startBatteryPercent,
109
+          let endBattery = session.endBatteryPercent else {
110
+        return false
111
+    }
112
+    
113
+    // Range must span ~100%
114
+    let range = startBattery - endBattery
115
+    guard range >= 90 else {
116
+        logWarning("Incomplete discharge: \(range)% only")
117
+        return false
118
+    }
119
+    
120
+    // Device must be ON during discharge
121
+    guard session.isCompletelyRecorded else {
122
+        logWarning("Session incomplete (meter disconnect)")
123
+        return false
124
+    }
125
+    
126
+    return true
127
+}
128
+```
129
+
130
+### Weighted moving average
131
+
132
+Pentru stabilitate, combinaţi multiple measurements:
133
+
134
+```swift
135
+func estimateCapacity(from sessions: [ChargeRecord]) -> Double? {
136
+    let validSessions = sessions.compactMap { session in
137
+        learnCapacityFromDischarge(session)
138
+    }
139
+    
140
+    guard !validSessions.isEmpty else { return nil }
141
+    
142
+    // Weight by recency: newer sessions count more
143
+    let weights = validSessions.indices.map { i in
144
+        Double(i + 1) / Double(validSessions.count)
145
+    }
146
+    
147
+    let weightedSum = zip(validSessions, weights)
148
+        .map { capacity, weight in capacity * weight }
149
+        .reduce(0, +)
150
+    
151
+    let capacityMah = weightedSum / weights.reduce(0, +)
152
+    
153
+    return capacityMah
154
+}
155
+```
156
+
157
+## API Public
158
+
159
+### Proprietăţi
160
+
161
+| Proprietate | Tip | Descriere |
162
+|---|---|---|
163
+| `nominalBatteryVoltage` | Double | V (ex: 3.7) |
164
+| `measuredCapacity` | Double? | mAh din learning |
165
+| `ratedCapacity` | Double? | mAh din device spec |
166
+| `capacity_Health` | Double | measured / rated (%) |
167
+| `lastCapacityMeasurement` | Date? | When learned |
168
+
169
+### Metode
170
+
171
+```swift
172
+// Learning
173
+func learnCapacity(from session: ChargeRecord) -> Double?
174
+func learnCapacity(from sessions: [ChargeRecord]) -> Double?
175
+
176
+// Validation
177
+func isValidCapacityMeasurement(_ mah: Double) -> Bool
178
+func isCompleteDischargeSession(_ session: ChargeRecord) -> Bool
179
+func estimateSessionCompleteness(_ session: ChargeRecord) -> Double
180
+
181
+// Health
182
+func batteryHealth() -> Double // measured / rated
183
+func isAgedBattery() -> Bool // health < 80%
184
+```
185
+
186
+## Comportamente critice
187
+
188
+### Incompletă sesiune → skip learning
189
+
190
+```
191
+Device: 30% → 15%
192
+Meter: record-ează 5 Wh
193
+⟹ Skip (partial discharge, nu putem infera capacity)
194
+
195
+Device: 100% → 5% (but meter lost signal @ 20%)
196
+⟹ Skip (incomplete recording)
197
+```
198
+
199
+**MUST**: Validate completeness înainte de learning
200
+
201
+### Multiple discharge cycles
202
+
203
+```
204
+Session 1: 100% → 0%, measured 12 Wh → 3200 mAh
205
+Session 2: 100% → 0%, measured 11.5 Wh → 3100 mAh
206
+Session 3: 100% → 0%, measured 11.2 Wh → 3000 mAh
207
+⟹ Average: 3100 mAh (weighted toward recent)
208
+```
209
+
210
+**SHOULD**: Combinaţi sesiuni (moving average)
211
+**MUST**: Keep history pentru trend analysis
212
+
213
+### Battery aging
214
+
215
+```
216
+Device created: 2024-01-01, rated 3000 mAh
217
+Measured capacities:
218
+  - 2024-01-15: 2950 mAh (98% health)
219
+  - 2024-06-15: 2700 mAh (90% health)
220
+  - 2025-01-15: 2400 mAh (80% health) ← aged
221
+⟹ Notify user: "Battery health below 80%"
222
+```
223
+
224
+**SHOULD**: Track degradation curve
225
+**SHOULD**: Notify @ 80% health
226
+**MAY**: Suggest battery replacement @ 70%
227
+
228
+## Testare
229
+
230
+### Unit tests
231
+
232
+```swift
233
+test_learnCapacity_ValidDischargeSession()
234
+test_learnCapacity_PartialDischarge_Ignored()
235
+test_learnCapacity_IncompleteRecording_Ignored()
236
+test_isCompleteDischargeSession_ValidRange()
237
+test_isCompleteDischargeSession_MinRange()
238
+test_estimateCapacity_MultiplesSessions()
239
+test_estimateCapacity_WeightedByRecency()
240
+test_capacityMustBePositive()
241
+test_capacityMustBeBounded()
242
+test_batteryHealth_Calculation()
243
+test_isAgedBattery_Threshold()
244
+```
245
+
246
+### Integration tests
247
+
248
+- [ ] Single complete discharge 100%→0%: capacity measured
249
+- [ ] Partial discharge: capacity NOT measured
250
+- [ ] Multiple sessions: weighted average
251
+- [ ] Aged battery detected @ 80%
252
+- [ ] Capacity persistent în Core Data
253
+- [ ] Battery health calculated correctly
254
+
255
+## Edge cases
256
+
257
+### Rapid discharge (high power draw)
258
+
259
+```
260
+Battery: 100% → 0% in 30 minutes
261
+Power draw: 50W constant
262
+Energy: large, measured accurately
263
+⟹ Normal discharge, capacity = energy / voltage
264
+```
265
+
266
+**SHOULD**: Accept și use pentru learning
267
+
268
+### Trickle charge (low power)
269
+
270
+```
271
+Battery: 95% → 100%
272
+Charging current: 10mA (very low)
273
+Duration: 4 hours
274
+Energy: negligible (~0.2Wh)
275
+⟹ Skip (too little data)
276
+```
277
+
278
+**MUST**: Ignore (capacity = energy/voltage → false low)
279
+
280
+### Voltage variance
281
+
282
+```
283
+Nominal voltage: 3.7V
284
+But actual range: 3.2V (low) → 4.2V (full)
285
+Energy measured: 11Wh
286
+Capacity calculation: depends on which voltage used
287
+```
288
+
289
+**SHOULD**: Use nominal (convention)
290
+**SHOULD**: Log warning dacă voltage deviates
291
+
292
+## Dependencies
293
+
294
+- [Charging Monitoring](./ChargingMonitoring.md): session data
295
+- [Charge Curve Storage](./ChargeCurveStorage.md): historical curves
296
+- Core Data: `ChargeSession.startBatteryPercent`, `endBatteryPercent`
297
+
298
+## References
299
+
300
+- [Powerbank Category](../Powerbank%20Category.md) — battery reporting modes
301
+- Scientific: https://en.wikipedia.org/wiki/Battery_capacity
+337 -0
Documentation/API Reference/ChargeCurveIsolation.md
@@ -0,0 +1,337 @@
1
+# Charge Curve Isolation Operation
2
+
3
+Izolarea porţiunii relevante a curbei de încărcare: eliminarea zgomotului pre/post-charging.
4
+
5
+## Responsabilități
6
+
7
+- Detectarea punctului de start real al încărcării (0 watts baseline)
8
+- Detectarea punctului de end real al încărcării (100% sau limit)
9
+- Eliminarea zgomotului (ambient AC, meter offset)
10
+- Determinarea timpului efectiv de charging
11
+
12
+## Problema
13
+
14
+**Raw data from meter:**
15
+```
16
+00:00 - 0.0W (pre-charge noise)
17
+00:30 - 0.5W (background draw)
18
+01:00 - 15.0W ← REAL START
19
+01:30 - 18.5W
20
+02:00 - 16.2W
21
+...
22
+05:00 - 2.5W (charging optimizer active)
23
+05:30 - 1.0W
24
+06:00 - 0.2W ← REAL END
25
+06:15 - 0.0W (post-charge, device removed)
26
+```
27
+
28
+**Sarcini:**
29
+1. Find real start (@ 01:00)
30
+2. Find real end (@ 06:00)
31
+3. Extract interval [01:00, 06:00]
32
+4. Ignore pre/post noise
33
+
34
+## Invarianţi
35
+
36
+- **MUST**: `real_start_time > measurement_start_time` (or equal)
37
+- **MUST**: `real_end_time <= measurement_end_time` (or equal)
38
+- **MUST**: `real_start < real_end`
39
+- **MUST**: Threshold determination trebuie consistent (nu random)
40
+- **SHOULD**: Isolated curve ≥ 90% din total recorded measurements
41
+- **MAY**: Isolated curve poate fi < 10% din total (very short charge)
42
+
43
+## Detection algorithm
44
+
45
+### Power threshold method
46
+
47
+```swift
48
+func findChargeStart(measurements: [Measurement]) -> Int? {
49
+    let powerThreshold = 5.0 // Watts
50
+    let minConsecutive = 3 // Measure must sustain > threshold for 3 samples
51
+    
52
+    var sustainCount = 0
53
+    for (i, measurement) in measurements.enumerated() {
54
+        if measurement.power > powerThreshold {
55
+            sustainCount += 1
56
+            if sustainCount >= minConsecutive {
57
+                return i - minConsecutive + 1
58
+            }
59
+        } else {
60
+            sustainCount = 0
61
+        }
62
+    }
63
+    return nil
64
+}
65
+
66
+func findChargeEnd(measurements: [Measurement]) -> Int? {
67
+    let powerThreshold = 1.0 // Watts (lower at end, trickle charge)
68
+    let minConsecutive = 5 // Stay below threshold for 5 samples
69
+    
70
+    var sustainCount = 0
71
+    for i in stride(from: measurements.count - 1, through: 0, by: -1) {
72
+        let measurement = measurements[i]
73
+        if measurement.power < powerThreshold {
74
+            sustainCount += 1
75
+            if sustainCount >= minConsecutive {
76
+                return i + 1
77
+            }
78
+        } else {
79
+            sustainCount = 0
80
+        }
81
+    }
82
+    return nil
83
+}
84
+```
85
+
86
+**Thresholds:**
87
+- Start: 5W (must exceed noise level)
88
+- End: 1W (lower, allows trickle detection)
89
+- Sustain: 3 samples @start, 5 @end (hysteresis)
90
+
91
+### Battery level method (fallback)
92
+
93
+Dacă dispositiv raportează procent:
94
+
95
+```swift
96
+func findChargeStartByBattery(measurements: [Measurement]) -> Int? {
97
+    // Find first battery level jump
98
+    for i in 1..<measurements.count {
99
+        let prev = measurements[i-1]
100
+        let curr = measurements[i]
101
+        
102
+        let batteryChange = (curr.batteryPercent ?? 0) - (prev.batteryPercent ?? 0)
103
+        if batteryChange > 1.0 {  // >1% jump = real charge
104
+            return i - 1
105
+        }
106
+    }
107
+    return nil
108
+}
109
+
110
+func findChargeEndByBattery(measurements: [Measurement]) -> Int? {
111
+    // Find last battery level change
112
+    for i in stride(from: measurements.count - 1, through: 1, by: -1) {
113
+        let prev = measurements[i-1]
114
+        let curr = measurements[i]
115
+        
116
+        let batteryChange = (curr.batteryPercent ?? 0) - (prev.batteryPercent ?? 0)
117
+        if batteryChange > 0.5 {  // Still charging
118
+            return i
119
+        }
120
+    }
121
+    return nil
122
+}
123
+```
124
+
125
+### Combined method (best)
126
+
127
+```swift
128
+func isolateChargeRange(measurements: [Measurement]) -> (start: Int, end: Int)? {
129
+    // Primary: use power threshold
130
+    guard var start = findChargeStart(measurements: measurements),
131
+          var end = findChargeEnd(measurements: measurements) else {
132
+        // Fallback: use battery level
133
+        guard var start = findChargeStartByBattery(measurements: measurements),
134
+              var end = findChargeEndByBattery(measurements: measurements) else {
135
+            return nil
136
+        }
137
+        return (start, end)
138
+    }
139
+    
140
+    // Validate result
141
+    guard start < end && (end - start) >= 10 else {
142
+        return nil  // Too short (< 10 seconds @ 1Hz)
143
+    }
144
+    
145
+    return (start, end)
146
+}
147
+```
148
+
149
+## API Public
150
+
151
+### Metode
152
+
153
+```swift
154
+// Detection
155
+func findChargeStart(measurements: [Measurement]) -> Int?
156
+func findChargeEnd(measurements: [Measurement]) -> Int?
157
+func isolateChargeRange(measurements: [Measurement]) -> (start: Int, end: Int)?
158
+
159
+// Extraction
160
+func extractIsolatedCurve(measurements: [Measurement]) 
161
+    -> [Measurement]?
162
+
163
+// Validation
164
+func isValidChargeRange(start: Int, end: Int, total: Int) -> Bool
165
+func estimateNoisePercentage(measurements: [Measurement]) -> Double
166
+```
167
+
168
+### Rezultat
169
+
170
+```swift
171
+struct IsolatedChargeData {
172
+    let originalMeasurements: [Measurement]
173
+    let isolatedMeasurements: [Measurement]
174
+    let startIndex: Int
175
+    let endIndex: Int
176
+    let realStartTime: Date
177
+    let realEndTime: Date
178
+    let noisePercentagePre: Double
179
+    let noisePercentagePost: Double
180
+    
181
+    var effectiveChargeTime: TimeInterval {
182
+        realEndTime.timeIntervalSince(realStartTime)
183
+    }
184
+}
185
+```
186
+
187
+## Comportamente critice
188
+
189
+### Threshold sensitivity
190
+
191
+**Problem: threshold prea mare**
192
+```
193
+Threshold: 10W
194
+Reality: charger 12W on iPhone 15
195
+Result: detectează corect ✓
196
+But: low-power charger 5W → missed (fails)
197
+```
198
+
199
+**Problem: threshold prea mic**
200
+```
201
+Threshold: 0.5W
202
+Reality: background AC noise 1.5W
203
+Result: detectează noise, invalid start (fails)
204
+```
205
+
206
+**Solution:** Adaptive threshold:
207
+```swift
208
+let baselineNoise = measurements.prefix(10).map { $0.power }.max() ?? 0
209
+let powerThreshold = baselineNoise * 1.5 + 2.0  // 50% margin + 2W
210
+```
211
+
212
+### Short charges
213
+
214
+```
215
+Charge duration: 30 seconds (e.g., quick top-up)
216
+Measurements: 30 samples @ 1Hz
217
+Noise isolation: 3 samples start + 5 samples end = 8 samples
218
+Remaining: 22 samples (73% valid)
219
+⟹ Valid
220
+```
221
+
222
+**SHOULD**: Accept short charges
223
+**MUST**: Still isolate pre/post
224
+
225
+### Noisy environment
226
+
227
+```
228
+Pre-charge noise: 2W baseline (poor outlet)
229
+Charge power: 18W
230
+Power jump: 16W (obvious)
231
+⟹ Easy to detect
232
+
233
+Vs.
234
+
235
+Pre-charge noise: 8W (strong interference)
236
+Charge power: 10W
237
+Power jump: 2W (ambiguous!)
238
+⟹ Hard, need fallback (battery level)
239
+```
240
+
241
+**SHOULD**: Use combined method (power + battery)
242
+**MUST**: Fall back if ambiguous
243
+
244
+### Charging profile changes
245
+
246
+```
247
+Curve:
248
+- 0-1h: 20W (fast charge phase)
249
+- 1-2h: 10W (medium phase)
250
+- 2-3h: 3W (taper phase)
251
+- 3h+: 0.5W (trickle/done)
252
+
253
+Isolated region: 0h-3h (valid)
254
+Threshold: 1W (catches all phases)
255
+⟹ Correct
256
+```
257
+
258
+**MUST**: Detect end even with tapering
259
+
260
+## Testare
261
+
262
+### Unit tests
263
+
264
+```swift
265
+test_findChargeStart_ValidThreshold()
266
+test_findChargeStart_NoiseIgnored()
267
+test_findChargeEnd_ValidThreshold()
268
+test_findChargeEnd_WithTrickleCharge()
269
+test_isolateChargeRange_ValidData()
270
+test_isolateChargeRange_TooShortIgnored()
271
+test_isolateChargeRange_HighNoise_FallbackToBattery()
272
+test_isValidChargeRange_StartBeforeEnd()
273
+test_isValidChargeRange_MinimumDuration()
274
+test_estimateNoisePercentage()
275
+test_extractIsolatedCurve_PreservesMeasurements()
276
+```
277
+
278
+### Integration tests
279
+
280
+- [ ] Raw data: 6h recording, 15 min real charge → correctly isolated
281
+- [ ] High pre-charge noise (8W) → still detected
282
+- [ ] Low power charger (5W) → detected
283
+- [ ] Multiple charge phases → end detected at trickle
284
+- [ ] Isolated curve used for energy calculation
285
+
286
+## Edge cases
287
+
288
+### No charge (meter ON, device not charging)
289
+
290
+```
291
+Measurements: all ~0W
292
+Start detection: fails (no power jump)
293
+End detection: fails (no charging detected)
294
+Result: (nil, nil)
295
+⟹ Skip session (valid, no charging occurred)
296
+```
297
+
298
+**MUST**: Handle gracefully
299
+
300
+### Meter added after charge started
301
+
302
+```
303
+Recording: 03:00-07:00 (4 hours)
304
+Real charge: 01:00-06:00 (5 hours, but missed first 2h)
305
+Measurements: 10W constant (already in charge phase)
306
+Start: 03:00 (best guess)
307
+End: 06:00 (detected from power drop)
308
+⟹ Partial isolation OK
309
+```
310
+
311
+**SHOULD**: Use what data we have
312
+**MAY**: Mark as "incomplete start"
313
+
314
+### USB power delivery with negotiation
315
+
316
+```
317
+Time 00:00: Meter ON, no device
318
+Time 00:10: Device plugged in, negotiation 100ms
319
+Time 00:12: Charging starts (power jump 20W)
320
+Samples: ?, ?, [19.5W, 19.8W, ...] ← isolated start
321
+⟹ Correct
322
+```
323
+
324
+**SHOULD**: OK, negotiation time negligible
325
+
326
+## Dependencies
327
+
328
+- [Charging Monitoring](./ChargingMonitoring.md): input measurements
329
+- [Charge Curve Storage](./ChargeCurveStorage.md): store isolated data
330
+- Core Data: battery levels (fallback)
331
+
332
+## Notes
333
+
334
+- Power threshold: device/charger dependent, may need tuning
335
+- Battery level fallback: only if power method ambiguous
336
+- Resolution: 1Hz (1000ms), ~1W precision typical
337
+- Legat: [Charge Session Integrity](../Charge%20Session%20Integrity%20and%20Conflict%20Healing.md)
+425 -0
Documentation/API Reference/ChargeCurveStorage.md
@@ -0,0 +1,425 @@
1
+# Charge Curve Storage Operation
2
+
3
+Stocarea şi retrieval-ul curbelor de încărcare în persistență.
4
+
5
+## Responsabilități
6
+
7
+- Persistență curbe în Core Data (sesiuni + measurements)
8
+- Sincronizare curbe în iCloud (CloudKit)
9
+- Compresie date (sampling, agregare)
10
+- Cleanup și archiving sesiuni vechi
11
+
12
+## Arhitectură
13
+
14
+### Core Data entities
15
+
16
+```
17
+ChargeSession
18
+├── id: UUID
19
+├── chargedDeviceID: UUID
20
+├── startTime: Date
21
+├── endTime: Date
22
+├── startBatteryPercent: Double?
23
+├── endBatteryPercent: Double?
24
+├── totalEnergyWh: Double
25
+└── checkpoints: [ChargeCheckpoint]
26
+
27
+ChargeCheckpoint (measurement)
28
+├── id: UUID
29
+├── sessionID: UUID
30
+├── timestamp: Date
31
+├── voltage: Double
32
+├── current: Double
33
+├── power: Double
34
+├── temperature: Double?
35
+└── batteryPercent: Double?
36
+```
37
+
38
+### Storage layers
39
+
40
+1. **In-memory cache**: Recent sessions (last 24h)
41
+2. **Local Core Data**: All sessions
42
+3. **iCloud CloudKit**: Synced sessions (optional, per user)
43
+4. **Archive**: Old sessions (JSON export, local only)
44
+
45
+```
46
+Memory cache (24h)
47
+       ↓
48
+   Core Data (local)
49
+   /           \
50
+  ↓             ↓
51
+ iCloud      Archive (JSON)
52
+(CloudKit)   (5+ years old)
53
+```
54
+
55
+## Invarianţi
56
+
57
+- **MUST**: Fiecare `ChargeSession` are `id` unic
58
+- **MUST**: Fiecare `ChargeCheckpoint` are reference valid la `sessionID`
59
+- **MUST**: Checkpoints ordonate cronologic în sesiune
60
+- **MUST**: `startTime < endTime` pentru completed sessions
61
+- **MUST**: CloudKit sync preserve integrity (no partial sessions)
62
+- **SHOULD**: Sesiuni < 5 minute nu se sincronizează (noise)
63
+- **MAY**: Archive sesiuni > 5 ani local-only
64
+
65
+## Save flow
66
+
67
+### 1. In-memory accumulation
68
+
69
+```swift
70
+let session = meter.startChargeRecord(for: device)
71
+// session object in-memory
72
+// accumulate measurements în session.measurements array
73
+
74
+while isCharging {
75
+    let measurement = meter.lastDataPoint
76
+    session.addMeasurement(measurement)  // In-memory
77
+}
78
+```
79
+
80
+**Properties:**
81
+- Rapid (no I/O)
82
+- Loss on app crash
83
+- Limits: max 1MB per session (~10k measurements @ 1Hz)
84
+
85
+### 2. Flush to Core Data
86
+
87
+```swift
88
+func endChargeRecord(_ session: ChargeRecord) {
89
+    // 1. Isolate valid curve
90
+    let isolated = isolateChargeRange(session.measurements)
91
+    
92
+    // 2. Save to Core Data
93
+    let coreDataSession = ChargeSession()
94
+    coreDataSession.id = session.id
95
+    coreDataSession.chargedDeviceID = device.id
96
+    coreDataSession.startTime = isolated.realStartTime
97
+    coreDataSession.endTime = isolated.realEndTime
98
+    coreDataSession.totalEnergyWh = calculateEnergy(isolated.measurements)
99
+    
100
+    // 3. Save checkpoints
101
+    for measurement in isolated.measurements {
102
+        let checkpoint = ChargeCheckpoint()
103
+        checkpoint.timestamp = measurement.timestamp
104
+        checkpoint.voltage = measurement.voltage
105
+        checkpoint.current = measurement.current
106
+        checkpoint.power = measurement.power
107
+        // ... other fields
108
+        coreDataSession.checkpoints.append(checkpoint)
109
+    }
110
+    
111
+    // 4. Persist
112
+    managedObjectContext.insert(coreDataSession)
113
+    try? managedObjectContext.save()
114
+}
115
+```
116
+
117
+**Guarantees:**
118
+- ACID transaction
119
+- Survives app crash
120
+- Instantly queryable
121
+
122
+### 3. CloudKit sync (async)
123
+
124
+```swift
125
+// NSPersistentCloudKitContainer handles automatically
126
+// New/modified ChargeSession → CloudKit
127
+// Happens in background, doesn't block UI
128
+```
129
+
130
+**Flow:**
131
+```
132
+Core Data save
133
+  ↓
134
+NSPersistentCloudKitContainer observes change
135
+  ↓
136
+Serializes to CloudKit record
137
+  ↓
138
+Uploads in background (when network available)
139
+  ↓
140
+Notifies on success/failure
141
+```
142
+
143
+**CloudSync constraints:**
144
+- **MUST**: Sessions < 5 min nu se syncă (noise filter)
145
+- **MUST**: Checkpoints limitate la max 1000 per CloudKit record
146
+- **SHOULD**: Aggregate samples dacă > 1000 checkpoints
147
+
148
+## Load flow
149
+
150
+### 1. Query Core Data
151
+
152
+```swift
153
+func loadSessions(for device: ChargedDevice) -> [ChargeSession] {
154
+    let request: NSFetchRequest<ChargeSession> = ChargeSession.fetchRequest()
155
+    request.predicate = NSPredicate(format: "chargedDeviceID == %@", device.id as NSUUID)
156
+    request.sortDescriptors = [NSSortDescriptor(key: "startTime", ascending: false)]
157
+    
158
+    let sessions = try? managedObjectContext.fetch(request)
159
+    return sessions ?? []
160
+}
161
+
162
+func loadCheckpoints(for session: ChargeSession) -> [ChargeCheckpoint] {
163
+    let request: NSFetchRequest<ChargeCheckpoint> = ChargeCheckpoint.fetchRequest()
164
+    request.predicate = NSPredicate(format: "sessionID == %@", session.id as NSUUID)
165
+    request.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: true)]
166
+    
167
+    let checkpoints = try? managedObjectContext.fetch(request)
168
+    return checkpoints ?? []
169
+}
170
+```
171
+
172
+**Performance:**
173
+- O(log n) for device lookup (indexed)
174
+- O(k) for checkpoint load (k = checkpoint count, typically 1000-5000)
175
+
176
+### 2. Reconstruct curve
177
+
178
+```swift
179
+func reconstructCurve(from session: ChargeSession) -> ChargeCurve {
180
+    let checkpoints = loadCheckpoints(for: session)
181
+    
182
+    return ChargeCurve(
183
+        startTime: session.startTime,
184
+        endTime: session.endTime,
185
+        totalEnergy: session.totalEnergyWh,
186
+        measurements: checkpoints.map { cp in
187
+            Measurement(
188
+                timestamp: cp.timestamp,
189
+                voltage: cp.voltage,
190
+                current: cp.current,
191
+                power: cp.power
192
+            )
193
+        }
194
+    )
195
+}
196
+```
197
+
198
+### 3. Cache in memory
199
+
200
+```swift
201
+// Keep last 24h in cache
202
+var recentCurves: [UUID: ChargeCurve] = [:]
203
+
204
+func cachedCurve(for sessionID: UUID) -> ChargeCurve? {
205
+    if let cached = recentCurves[sessionID] {
206
+        return cached  // Memory hit
207
+    }
208
+    
209
+    // Core Data miss → fetch + cache
210
+    guard let coreDataSession = loadSessionFromCoreData(sessionID) else {
211
+        return nil
212
+    }
213
+    
214
+    let curve = reconstructCurve(from: coreDataSession)
215
+    recentCurves[sessionID] = curve
216
+    return curve
217
+}
218
+```
219
+
220
+## Compression strategies
221
+
222
+### For frequent access (last 7 days)
223
+
224
+**Store:** All 1Hz measurements
225
+**Cost:** ~1000 checkpoints per session
226
+
227
+### For occasional access (7-365 days)
228
+
229
+**Strategy:** Downsample to 10Hz
230
+```swift
231
+func downsampleCurve(measurements: [Measurement], factor: Int) -> [Measurement] {
232
+    return measurements.enumerated()
233
+        .filter { $0.offset % factor == 0 }
234
+        .map { $0.element }
235
+}
236
+```
237
+
238
+**Reduction:** 10x fewer points
239
+**Loss:** Minimal (still captures curve shape)
240
+**Cost:** ~100 checkpoints per session
241
+
242
+### For archive (> 1 year)
243
+
244
+**Strategy:** Store metadata only
245
+```swift
246
+struct SessionMetadata {
247
+    let id: UUID
248
+    let chargedDeviceID: UUID
249
+    let startTime: Date
250
+    let endTime: Date
251
+    let totalEnergyWh: Double
252
+    let peakPowerW: Double
253
+    let averagePowerW: Double
254
+    // No measurements stored
255
+}
256
+```
257
+
258
+**Cost:** Minimal (constant per session)
259
+**Retrieval:** Summary only, not full curve
260
+
261
+## CloudKit constraints
262
+
263
+### Record size limits
264
+
265
+- CloudKit record: max 4 MB
266
+- Checkpoints JSON: ~100 bytes per checkpoint
267
+- Max checkpoints per record: ~40k
268
+- Typical session: 1000-5000 checkpoints
269
+
270
+### Sync strategy
271
+
272
+```swift
273
+func syncSessionsToCloudKit() {
274
+    let allSessions = loadSessionsFromCoreData()
275
+    
276
+    for session in allSessions {
277
+        // Filter: only sessions > 5 minutes
278
+        guard session.duration >= 300 else { continue }
279
+        
280
+        // Aggregate: if checkpoints > 2000, downsample
281
+        var checkpoints = loadCheckpoints(for: session)
282
+        if checkpoints.count > 2000 {
283
+            checkpoints = downsampleCurve(checkpoints, factor: 2)
284
+        }
285
+        
286
+        // Push to CloudKit (NSPersistentCloudKitContainer handles)
287
+    }
288
+}
289
+```
290
+
291
+**MUST**: Filtrare sesiuni mici (< 5 min)
292
+**SHOULD**: Agregate checkpoint-uri dacă > 2000
293
+**MAY**: Archive > 1 an local-only
294
+
295
+## API Public
296
+
297
+### Save
298
+
299
+```swift
300
+// Save completed session
301
+func saveChargeSession(_ session: ChargeRecord)
302
+// Isolates curve → persists to Core Data → CloudKit async
303
+
304
+// Append checkpoint
305
+func appendCheckpoint(_ measurement: Measurement, to sessionID: UUID)
306
+// In-memory only, flushed on session end
307
+```
308
+
309
+### Load
310
+
311
+```swift
312
+// Query by device
313
+func loadSessions(for device: ChargedDevice) -> [ChargeSession]
314
+
315
+// Load single session
316
+func loadSession(id: UUID) -> ChargeSession?
317
+
318
+// Load curve with checkpoints
319
+func loadFullCurve(sessionID: UUID) -> ChargeCurve?
320
+
321
+// Load metadata only (fast)
322
+func loadSessionMetadata(sessionID: UUID) -> SessionMetadata?
323
+```
324
+
325
+### Cleanup
326
+
327
+```swift
328
+// Archive old sessions (local)
329
+func archiveOldSessions(olderThan: Date) -> Int
330
+// Returns count archived
331
+
332
+// Cleanup from iCloud
333
+func deleteFromCloudKit(sessionID: UUID)
334
+// MUST: permanent (CloudKit deletion, can't undo)
335
+```
336
+
337
+## Comportamente critice
338
+
339
+### Concurrent saves
340
+
341
+```
342
+User: Start session on meter A
343
+App: Saves session A
344
+User: Also start session on meter B
345
+App: Saves session B
346
+⟹ Both saved (transactions independent)
347
+```
348
+
349
+**MUST**: Core Data transactions isolate (ACID)
350
+**MUST**: CloudKit syncs maintain order
351
+
352
+### CloudKit unavailable
353
+
354
+```
355
+Device offline
356
+App saves to Core Data ✓
357
+CloudKit sync queued
358
+User goes online
359
+Sync resumes automatically
360
+⟹ Transparent
361
+```
362
+
363
+**MUST**: Queue pending changes
364
+**SHOULD**: Retry with backoff
365
+**MUST**: Persist queue to disk (survive app restart)
366
+
367
+### Duplicate on restore
368
+
369
+```
370
+Device A: 100 sessions synced to iCloud
371
+User: Restore from backup
372
+Device B: Pulls 100 sessions
373
+Device A: Restarts, sees 100 sessions (already synced)
374
+⟹ Deduplication needed
375
+```
376
+
377
+**MUST**: Deduplicate by session ID
378
+**SHOULD**: Keep most recent version
379
+
380
+## Testare
381
+
382
+### Unit tests
383
+
384
+```swift
385
+test_saveSession_PersistsToCoreData()
386
+test_loadSessions_ByDevice()
387
+test_loadCheckpoints_Ordered()
388
+test_downsampleCurve_ReducesFactor()
389
+test_cloudSyncFilters_SessionsUnder5Min()
390
+test_archiveOldSessions_By Date()
391
+test_deduplicateOnRestore()
392
+```
393
+
394
+### Integration tests
395
+
396
+- [ ] Save session → reload → same data
397
+- [ ] CloudKit sync without network (queued)
398
+- [ ] Resume CloudKit sync when online
399
+- [ ] Downsample 7+ day old sessions
400
+- [ ] Archive 1+ year old sessions
401
+- [ ] Delete from CloudKit (permanent)
402
+
403
+## Performance considerations
404
+
405
+| Operation | Time | Notes |
406
+|---|---|---|
407
+| Save session | < 100ms | Core Data write |
408
+| Load 100 sessions | < 50ms | Indexed query |
409
+| Load curve (1000 pts) | < 200ms | Reconstruct array |
410
+| CloudKit sync | ~1-5s | Network dependent |
411
+| Downsample 5000 pts | < 20ms | CPU bound |
412
+
413
+## Dependenţe
414
+
415
+- Core Data: NSManagedObjectContext
416
+- CloudKit: NSPersistentCloudKitContainer
417
+- [Charging Monitoring](./ChargingMonitoring.md): input sessions
418
+- [Charge Curve Isolation](./ChargeCurveIsolation.md): isolated data
419
+- File system: archiving
420
+
421
+## References
422
+
423
+- [Charge Session Integrity](../Charge%20Session%20Integrity%20and%20Conflict%20Healing.md)
424
+- Core Data Performance Tuning: https://developer.apple.com/videos/play/wwdc2021/10190/
425
+- CloudKit limits: https://developer.apple.com/icloud/cloudkit/
+147 -0
Documentation/API Reference/ChargedDevice.md
@@ -0,0 +1,147 @@
1
+# ChargedDevice Entity
2
+
3
+Reprezentarea unui dispozitiv care se încarcă prin meter.
4
+
5
+## Responsabilități
6
+
7
+- Modelarea unui dispozitiv (iPhone, Watch, Charger, Powerbank, Other)
8
+- Gestionarea sesiunilor de încărcare
9
+- Colectarea de măsurători durante sesiune
10
+- Calculul integrității şi detectării conflictelor
11
+
12
+## Invarianţi
13
+
14
+- **MUST**: Fiecare ChargedDevice are un `id` (UUID) unic
15
+- **MUST**: O sesiune de încărcare are timestamp-uri valide: `startTime <= currentTime <= endTime`
16
+- **MUST**: Măsurătorile într-o sesiune sunt ordonate cronologic
17
+- **MUST**: O sesiune activă pe un dispozitiv trebuie să aibă un singur Meter asociat
18
+- **MUST**: Energia calculată (Wh) trebuie să fie ne-negativă
19
+- **SHOULD**: Un device cu 0 măsurători ar trebui marcat pentru curatare pe CloudKit
20
+- **MAY**: Duplicate sessions pe același MAC pot fi mergate după detectare
21
+
22
+## API Public
23
+
24
+### Proprietăţi
25
+
26
+| Proprietate | Tip | Descriere | Observaţii |
27
+|---|---|---|---|
28
+| `id` | UUID | Identificator unic | Generat la creare |
29
+| `deviceName` | String | Nume ales de utilizator | De ex: "My iPhone 15" |
30
+| `deviceClass` | ChargedDeviceClass | Tip: iPhone, Watch, Charger, Powerbank, Other | |
31
+| `kind` | ChargedDeviceKind | Device sau Charger | Derivat din `deviceClass` |
32
+| `createdAt` | Date | Data creării | Immutable |
33
+| `chargeRecords` | [ChargeRecord] | Lista sesiunilor de încărcare | Sorted by startTime |
34
+| `totalEnergy` | Double | Total Wh consumat/furnizat | Calculat din sesiuni |
35
+| `lastChargedAt` | Date? | Data ultimei sesiuni | Nullable |
36
+
37
+### Metode
38
+
39
+```swift
40
+// Gestionare sesiuni
41
+func startSession(meter: Meter) -> ChargeRecord
42
+// MUST: sessionID este UUID unic
43
+// MUST: startTime = now()
44
+// MUST: retur ChargeRecord cu status "active"
45
+
46
+func recordMeasurement(_ measurement: Measurement, in session: ChargeRecord)
47
+// MUST: measurement.timestamp > session.startTime
48
+// SHOULD: measure-urile sunt colectate la ~1Hz
49
+// MUST: nu merge dacă sesiunea e finalizată
50
+
51
+func endSession(_ record: ChargeRecord)
52
+// MUST: endTime = now()
53
+// MUST: calculează total energy din măsurători
54
+// MUST: marchez sesiunea ca "completed"
55
+
56
+func recalculateTotalEnergy()
57
+// Recalculează sum-ul de Wh din toate sesiunile
58
+// SHOULD: se apelează după conflict resolution
59
+
60
+// Naming
61
+func renameToDevice(_ newName: String)
62
+// MUST: actualizează proprietatea şi persistă
63
+
64
+// Conflict handling
65
+func mergeWith(_ other: ChargedDevice, keepingRecords: Bool = true)
66
+// MUST: combină sessions de pe ambele device-uri
67
+// MUST: remove-ă duplicates după MAC address
68
+// SHOULD: logs merge operation pentru debug
69
+```
70
+
71
+## Comportamente critice
72
+
73
+### Sesiuni de încărcare
74
+
75
+- **MUST**: Doar o singură sesiune per device poate fi activă la orice moment
76
+- **MUST**: Sesiunile completate sunt imutable
77
+- **SHOULD**: Sesiunile care durează > 24h sunt marcate cu warning
78
+- **MAY**: Sesiuni orphaned (meter deconectat > 1h) pot fi finalizate automat
79
+
80
+### Calculul energiei
81
+
82
+- **MUST**: Energia = ∑(V * A * Δt) pentru fiecare interval
83
+- **MUST**: Unitatea trebuie să fie Wh (Watt-hours)
84
+- **SHOULD**: Energia negativă în sesiune = problema în măsurători, loghează warning
85
+
86
+### Conflict detection și rezoluție
87
+
88
+Se declanşează când:
89
+1. Două sesiuni cu aceeași categorie de device în same time window
90
+2. Două sesiuni pe aceeași meter cu overlap temporal
91
+
92
+Rezoluţie:
93
+- **MUST**: Sesiunea cu mai multe măsurători = "winner"
94
+- **MUST**: Energiile sunt combinate (∑)
95
+- **SHOULD**: Old session data este archived (Core Data snapshot)
96
+- **MAY**: Notifică utilizator despre merge
97
+
98
+### State management
99
+
100
+- **MUST**: Active session nu poate fi schimbat în mod normal (read-only)
101
+- **MUST**: State tranzițion: idle → active → completed (nu poate reveni)
102
+- **SHOULD**: Completed sesiuni sunt persistent în Core Data
103
+
104
+## Testare
105
+
106
+### Unit tests
107
+
108
+```swift
109
+// Sesiuni
110
+test_startSessionCreatesValidRecord()
111
+test_endSessionCalculatesEnergy()
112
+test_activeSessions_CanOnlyBeOne()
113
+test_sessionTimeValidation()
114
+
115
+// Măsurători
116
+test_recordMeasurementFailsIfSessionEnded()
117
+test_measurementsAreOrdered()
118
+test_emptySessionsCalculateZeroEnergy()
119
+
120
+// Conflict
121
+test_mergeDetectsDuplicateSessions()
122
+test_mergeKeepsMoreCompleteMeasurements()
123
+test_mergeRecalculates_TotalEnergy()
124
+
125
+// Naming
126
+test_renameUpdatesPersistence()
127
+```
128
+
129
+### Integration tests
130
+
131
+- [ ] Device apare în Charged Devices list după start sesiune
132
+- [ ] Sesiuni complete salvate în Core Data
133
+- [ ] CloudKit sync nu pierde sesiuni
134
+- [ ] Energiile sunt consistente după conflict merge
135
+- [ ] Device name e persistent
136
+
137
+## Dependenţe
138
+
139
+- `Meter`: conectare şi măsurători
140
+- `ChargeInsightsStore`: persistență sesiuni
141
+- `CloudKitSync`: replicare iCloud
142
+
143
+## Notes
144
+
145
+Documentaţie asoc:
146
+- [Charge Session Integrity](../Charge%20Session%20Integrity%20and%20Conflict%20Healing.md)
147
+- [No Ampere-Hours in UI](../No%20Ampere-Hours%20in%20UI%20or%20Model.md)
+245 -0
Documentation/API Reference/ChargingMonitoring.md
@@ -0,0 +1,245 @@
1
+# Charging Monitoring Operation
2
+
3
+Monitorizarea unei sesiuni de încărcare: colectarea măsurătorilor, izolarea curbei şi calculul energiei.
4
+
5
+## Responsabilități
6
+
7
+- Orchestrează o sesiune de încărcare complet (start → record → end)
8
+- Colectează măsurători de la meter la intervale regulate (~1Hz)
9
+- Izolează porţiunea relevantă a curbei (fără pre/post charging noise)
10
+- Calculează totalul de energie consumată (Wh)
11
+- Persistă date în Core Data + iCloud
12
+
13
+## Invarianţi
14
+
15
+- **MUST**: Doar o sesiune activă per meter la orice moment
16
+- **MUST**: Măsurătorile sunt cronologice (sortate după timestamp)
17
+- **MUST**: Start time < end time (sauf pentru sesiunile active)
18
+- **MUST**: Energia totală ≥ 0 (nu poate fi negativă)
19
+- **MUST**: O sesiune completată este imutabilă
20
+- **SHOULD**: O sesiune durează între 5 minute şi 48 ore
21
+- **MAY**: Sesiuni orphane (meter deconectat) pot fi finalizate automat
22
+
23
+## Stadiile sesiunii
24
+
25
+```
26
+idle
27
+  ↓
28
+startSession() → starting
29
+  ↓
30
+recordMeasurement() → active (repeat)
31
+  ↓
32
+endSession() → ended
33
+  ↓
34
+completed (immutable)
35
+```
36
+
37
+## Lifecycle
38
+
39
+### 1. Start sesiune
40
+
41
+```swift
42
+let session = meter.startChargeRecord(for: device)
43
+// ⟹ ChargeRecord(
44
+//    id: UUID(),
45
+//    sessionID: UUID(),
46
+//    startTime: Date(),
47
+//    chargedDeviceID: device.id,
48
+//    meterID: meter.id,
49
+//    measurements: [],
50
+//    totalEnergy: 0
51
+// )
52
+```
53
+
54
+**MUST:**
55
+- `startTime` = `Date.now`
56
+- `sessionID` = UUID unic
57
+- `measurements` array = empty
58
+- `totalEnergy` = 0
59
+- Marchez sesiunea ca "active"
60
+
61
+**SHOULD:**
62
+- Notify UI: "Recording started"
63
+- Start periodic measurement requests (1Hz timer)
64
+- Log sesiune în analytics
65
+
66
+### 2. Record measurement
67
+
68
+```swift
69
+while session.isActive {
70
+    let measurement = meter.lastDataPoint
71
+    session.addMeasurement(measurement)
72
+}
73
+```
74
+
75
+**Frecvenţă**: 1Hz (1000ms interval)
76
+
77
+**MUST:**
78
+- `measurement.timestamp > session.startTime`
79
+- Măsurătorile ordonate cronologic
80
+- Ignore duplicate timestamps
81
+- Validare: voltage, current ≥ 0
82
+
83
+**SHOULD:**
84
+- Drop măsurători dacă queue > 500 items
85
+- Skip interval dacă meter nu are date nouă
86
+- Log invalid measurements (don't crash)
87
+
88
+**MAY:**
89
+- Adjust frecvenţă dacă battery low (500ms → 2s)
90
+- Throttle dacă temperatura critică
91
+
92
+### 3. End sesiune
93
+
94
+```swift
95
+meter.endChargeRecord(session)
96
+```
97
+
98
+**MUST:**
99
+- `endTime` = `Date.now`
100
+- Calculate `totalEnergy` = ∑(V × A × Δt) pentru fiecare interval
101
+- Call [Charge Curve Isolation](./ChargeCurveIsolation.md) pentru obţine valid range
102
+- Marchez sesiunea ca "completed"
103
+- Salvează în Core Data sync
104
+
105
+**SHOULD:**
106
+- Recalculează media power (watts)
107
+- Calculează duration (seconds)
108
+- Notify UI: "Recording completed"
109
+- Archive sesiunea (backup iCloud)
110
+
111
+**MAY:**
112
+- Trigger conflict resolution (dacă detecta duplicate-uri)
113
+- Trigger capacity learning (dacă sesiune completă)
114
+
115
+## API Public
116
+
117
+### Proprietăţi sesiune
118
+
119
+| Proprietate | Tip | Descriere |
120
+|---|---|---|
121
+| `id` | UUID | Identificator unic |
122
+| `sessionID` | UUID | Reference în persistență |
123
+| `chargedDeviceID` | UUID | Device monitorizat |
124
+| `meterID` | UUID | Meter folosit |
125
+| `startTime` | Date | Moment start |
126
+| `endTime` | Date? | Moment end (nil dacă active) |
127
+| `measurements` | [Measurement] | Puncte colectate |
128
+| `totalEnergy` | Double | Wh total |
129
+| `isActive` | Bool | Status curent |
130
+| `duration` | TimeInterval | end - start |
131
+| `peakPower` | Double | Max watts |
132
+| `averagePower` | Double | Mean watts |
133
+
134
+### Metode
135
+
136
+```swift
137
+// Orchestrare
138
+func startChargeRecord(for device: ChargedDevice) -> ChargeRecord
139
+func recordMeasurement(_ measurement: Measurement, in session: ChargeRecord)
140
+func endChargeRecord(_ session: ChargeRecord) -> ChargeRecord
141
+
142
+// Calculaţii
143
+func calculateTotalEnergy(measurements: [Measurement]) -> Double
144
+func calculatePeakPower(measurements: [Measurement]) -> Double
145
+func calculateAveragePower(measurements: [Measurement]) -> Double
146
+
147
+// Persistență
148
+func saveSession(_ record: ChargeRecord)
149
+func loadSession(id: UUID) -> ChargeRecord?
150
+```
151
+
152
+## Comportamente critice
153
+
154
+### Timeout pe sesiuni
155
+
156
+- **MUST**: Dacă nu primim măsurători > 5 min, sesiunea e considerat "stale"
157
+- **SHOULD**: Notifică utilizator: "No data received, disconnect?"
158
+- **MAY**: Auto-finalizează după 10 min inactivitate
159
+
160
+### Validare măsurători
161
+
162
+```swift
163
+func isValidMeasurement(_ m: Measurement) -> Bool {
164
+    // Voltage: 0V — 30V (USB + other)
165
+    // Current: 0A — 10A (typical chargers)
166
+    // Power: 0W — 300W (practical limit)
167
+    return m.voltage >= 0 && m.voltage <= 30 &&
168
+           m.current >= 0 && m.current <= 10 &&
169
+           m.power >= 0 && m.power <= 300
170
+}
171
+```
172
+
173
+**MUST**: Drop invalide measurements
174
+**SHOULD**: Log warnings pentru edge-case values
175
+
176
+### Merge duplicate sessions
177
+
178
+Se declanşează când:
179
+- Două sesiuni cu overlap temporal > 50%
180
+- Aceeaşi device + meter
181
+
182
+Rezoluţie:
183
+- **MUST**: Keep sesiunea cu mai multe măsurători
184
+- **MUST**: Combină energiile: `total = session1.energy + session2.energy`
185
+- **SHOULD**: Archive old session version
186
+
187
+### Energia negativă (edge case)
188
+
189
+Cauze posibile:
190
+1. Meter inversează polarity (rare)
191
+2. Măsurători corupte
192
+3. Bug în parsing
193
+
194
+**SHOULD**: Log warning
195
+**MAY**: Absolute value: `energy = abs(energy)`
196
+**NEVER**: Discard sesiune
197
+
198
+## Testare
199
+
200
+### Unit tests
201
+
202
+```swift
203
+// Start/end
204
+test_startSessionCreatesValidRecord()
205
+test_endSessionCalculatesTotalEnergy()
206
+
207
+// Measurements
208
+test_recordMeasurement_AddsToSession()
209
+test_recordMeasurement_FailsIfSessionEnded()
210
+test_measurementsAreOrdered()
211
+test_invalidMeasurement_IsDropped()
212
+
213
+// Calculations
214
+test_calculateTotalEnergy_WithValidData()
215
+test_calculateTotalEnergy_WithGaps()
216
+test_calculatePeakPower()
217
+test_calculateAveragePower()
218
+
219
+// Validation
220
+test_sessionTimeValidity()
221
+test_sessionDuration_Max48Hours()
222
+test_activeSessions_OnlyOne()
223
+```
224
+
225
+### Integration tests
226
+
227
+- [ ] Full cycle: start → record 100 samples → end
228
+- [ ] Sesiune pe device A, apoi device B (same meter)
229
+- [ ] Meter disconnect → auto-finalize
230
+- [ ] Energy = ∑ = match manual calculation
231
+- [ ] Sesiune persistent după app restart
232
+
233
+## Dependenţe
234
+
235
+- [Meter.md](./Meter.md): `meter.lastDataPoint`, state
236
+- [Charge Curve Isolation](./ChargeCurveIsolation.md): extract valid range
237
+- [Capacity Measurement](./CapacityMeasurement.md): capacity learning
238
+- [Charge Curve Storage](./ChargeCurveStorage.md): persistence
239
+- Core Data: `ChargeSession` entity
240
+
241
+## Notes
242
+
243
+- Time-series resolution: 1Hz (1000ms)
244
+- Energy formula: `E = ∑(V × A × Δt)` (integral numerical)
245
+- Legat: [Charge Session Integrity](../Charge%20Session%20Integrity%20and%20Conflict%20Healing.md)
+279 -0
Documentation/API Reference/CloudKitSync.md
@@ -0,0 +1,279 @@
1
+# CloudKit Sync
2
+
3
+Mecanismul de replicare a datelor către iCloud.
4
+
5
+## Arhitectură
6
+
7
+### Components
8
+
9
+1. **NSPersistentCloudKitContainer**: gestionează Core Data + CloudKit automat
10
+2. **CloudDeviceSettingsStore**: wrapper pe NSManagedObjectContext
11
+3. **Core Data Model**: `CKModel.xcdatamodeld` (curent: USB_Meter 2)
12
+4. **CloudKit Container**: `iCloud.ro.xdev.USB-Meter`
13
+
14
+### Model versioning
15
+
16
+```
17
+USB_Meter 1 (original)
18
+  ↓
19
+USB_Meter 2 (fix: removed uniqueness constraints)
20
+  ↓
21
+USB_Meter 3? (future)
22
+```
23
+
24
+**Curent**: USB_Meter 2 (schema v20)
25
+
26
+## Data Schema
27
+
28
+### DeviceSettings entity (Core Data)
29
+
30
+| Field | Type | Notes |
31
+|-------|------|-------|
32
+| `id` | UUID | Primary key |
33
+| `macAddress` | String | Optional (for migration) |
34
+| `meterName` | String | Chosen by user |
35
+| `tc66TemperatureUnit` | String | "celsius" / "fahrenheit" |
36
+| `createdAt` | Date | Immutable |
37
+| `updatedAt` | Date | Last sync timestamp |
38
+| `connectionMetadata` | JSON blob | Device, timestamp, expiry |
39
+| `discoveryMetadata` | JSON blob | Last seen, seen by |
40
+| `cloudKitRecordID` | String | Reference toward CloudKit |
41
+
42
+### Invarianţi
43
+
44
+- **MUST**: `macAddress` nu are `uniquenessConstraint` (not CloudKit safe)
45
+- **MUST**: Chiar dacă `macAddress` optional, duplicate entries ar trebui merged
46
+- **MUST**: `meterName` e unic per meter (no machine-generated names)
47
+- **MUST**: `updatedAt` se schimbă la fiecare sync
48
+- **SHOULD**: `connectionMetadata.expiresAt` = now() + 24h
49
+
50
+## Sync Lifecycle
51
+
52
+### Push (local → CloudKit)
53
+
54
+```
55
+User changes meterName
56
+  ↓
57
+AppData calls cloudStore.upsertDeviceSettings(...)
58
+  ↓
59
+NSManagedObjectContext saves
60
+  ↓
61
+NSPersistentCloudKitContainer auto-uploads to CloudKit
62
+  ↓
63
+CloudKit replica updated
64
+  ↓
65
+Other devices see change via NSPersistentCloudKitContainerEventChangeNotification
66
+```
67
+
68
+- **MUST**: Core Data save trebuie să se afle pe main thread
69
+- **MUST**: CloudKit sync e asincronă (fire-and-forget)
70
+- **SHOULD**: Notify UI după local save (don't wait for CloudKit)
71
+- **MAY**: Log upload errors
72
+
73
+### Pull (CloudKit → local)
74
+
75
+```
76
+iCloud change appears
77
+  ↓
78
+NSPersistentCloudKitContainer notifies
79
+  ↓
80
+AppData observes NSPersistentCloudKitContainerEventChangeNotification
81
+  ↓
82
+Merge remote change with local state
83
+  ↓
84
+Core Data context updated
85
+  ↓
86
+UI refreshes
87
+```
88
+
89
+- **MUST**: Merge strategy = "last write wins" (basate pe timestamp)
90
+- **MUST**: Conflict resolution e automat (NSMergeByPropertyObjectTrumpMergePolicy)
91
+- **SHOULD**: Loghează merge pentru debugging
92
+- **MAY**: Notifică user dacă conflict major
93
+
94
+### Conflict scenarios
95
+
96
+**Scenario 1**: Same meter renamed on two devices simultaneously
97
+```
98
+Device A: "Kitchen Meter" → "Main Meter"  (10:00:00)
99
+Device B: "Kitchen Meter" → "Lab Meter"   (10:00:05)
100
+```
101
+Resolution: Last timestamp wins → "Lab Meter" (Device B @ 10:00:05)
102
+
103
+**Scenario 2**: Duplicate entries cu aceeași MAC
104
+```
105
+Device A: macAddress = "AA:BB:CC:DD:EE:FF", meterName = "Meter 1"
106
+Device B (after restore): macAddress = "AA:BB:CC:DD:EE:FF", meterName = "Meter 1"
107
+```
108
+Resolution: Merge duplicate entries, keep one record (last write wins)
109
+
110
+## Discovery Throttling
111
+
112
+### Logică
113
+
114
+```swift
115
+func recordDiscovery(for macAddress: String) {
116
+    let lastSeen = discoveryMetadata[macAddress]?.lastSeen
117
+    let now = Date()
118
+    
119
+    if now.timeIntervalSince(lastSeen ?? .distantPast) >= 120 {
120
+        // OK: permit sync
121
+        cloudStore.recordDiscovery(macAddress, discoveredAt: now)
122
+    }
123
+    // else: skip (under throttle window)
124
+}
125
+```
126
+
127
+- **MUST**: Max 1 discovery record per device per 120s
128
+- **SHOULD**: Throttle pe app level (nu pe CloudKit)
129
+- **MUST**: Reseta timer dacă device disconnect-ează
130
+- **REASON**: Prevent CloudKit thrashing din repeat BT advertisements
131
+
132
+### Impact
133
+
134
+- BT scan at 0s, 30s, 60s, 90s, 120s, ... (periodc)
135
+- Doar descoperirile la 0s, 120s, 240s, ... sunt syncate la CloudKit
136
+- Alte descoperiri sunt cached local
137
+
138
+## Rebuild logic
139
+
140
+### Background
141
+
142
+Issue (March 2025):
143
+- Old `uniquenessConstraint` pe `macAddress` = incompatibil cu CloudKit
144
+- Old rebuild logic: delete ALL + recreate = delete storm în CloudKit
145
+- Old naming: `meterName` = platform model ("iPad") = irelevant pe CloudKit
146
+
147
+### Fix
148
+
149
+**Schema migration**: USB_Meter 1 → USB_Meter 2
150
+- Removed `uniquenessConstraint`
151
+- Made `macAddress` optional
152
+- Changed device name = hostname (all platforms)
153
+
154
+**Rebuild refactoring**: `rebuildCanonicalStoreIfNeeded()`
155
+```swift
156
+func rebuildCanonicalStoreIfNeeded(newVersion: Int) {
157
+    if cloudStoreRebuildVersion >= newVersion { return }
158
+    
159
+    // Update winner in-place, delete only duplicates
160
+    let groupedByMAC = groupEntries(by: \.macAddress)
161
+    for (mac, entries) in groupedByMAC {
162
+        guard entries.count > 1 else { continue }
163
+        let winner = entries.max(by: \.updatedAt)
164
+        let losers = entries.filter { $0.id != winner.id }
165
+        
166
+        // Delete losers only, keep winner
167
+        for loser in losers {
168
+            context.delete(loser)
169
+        }
170
+    }
171
+    
172
+    cloudStoreRebuildVersion = newVersion
173
+    try? context.save()
174
+}
175
+```
176
+
177
+- **MUST**: Update winner în-place (nu delete+recreate)
178
+- **MUST**: Delete doar duplicate entries (nu pe toti)
179
+- **MUST**: Set `cloudStoreRebuildVersion = 3` după fix
180
+- **REASON**: Prevent delete storms în CloudKit
181
+
182
+### Version history
183
+
184
+- **v1**: Original schema (cu uniqueness constraint)
185
+- **v2**: First rebuild (delete all + recreate) — DEPRECATED
186
+- **v3**: Correct rebuild (update in-place + delete duplicates only)
187
+
188
+## Legacy data migration
189
+
190
+### NSUbiquitousKeyValueStore (deprecated)
191
+
192
+```swift
193
+// Legacy stores
194
+let meterNames = NSUbiquitousKeyValueStore.default.dictionary(forKey: "MeterNames")
195
+// → {macAddress → meterName}
196
+
197
+let tempUnits = NSUbiquitousKeyValueStore.default.dictionary(forKey: "TC66TemperatureUnits")
198
+// → {macAddress → unit}
199
+```
200
+
201
+### Migration path
202
+
203
+1. Read from KV store
204
+2. For each MAC address:
205
+   ```swift
206
+   cloudStore.upsertDeviceSettings(
207
+       macAddress: mac,
208
+       meterName: meterNames[mac],
209
+       tc66TemperatureUnit: tempUnits[mac]
210
+   )
211
+   ```
212
+3. Mark as migrated în defaults
213
+
214
+- **MUST**: Menţine KV store pentru backward compat
215
+- **SHOULD**: Migrate pe startup dacă not migrated
216
+- **MAY**: Drop KV store la next major version
217
+
218
+## Testing
219
+
220
+### Unit tests
221
+
222
+```swift
223
+test_cloudStoreSaves_ToNSManagedObjectContext()
224
+test_upsertDeviceSettings_CreatesOrUpdates()
225
+test_discoveryThrottling_RespectsTiming()
226
+test_conflictResolution_LastWriteWins()
227
+test_rebuildCanonicalStore_UpdatesWinner_DeletesDuplicates()
228
+test_macAddressNoDuplicates_AfterRebuild()
229
+```
230
+
231
+### Integration tests
232
+
233
+- [ ] Multiple devices sync settings via CloudKit
234
+- [ ] Conflict detected and resolved
235
+- [ ] Rebuild v3 migrates old data correctly
236
+- [ ] Discovery throttling prevents CloudKit thrashing
237
+- [ ] Legacy KV store data migrates to Core Data
238
+
239
+## Error handling
240
+
241
+### Network errors
242
+
243
+```
244
+Error: Network unavailable
245
+→ Fail fast (don't retry immediately)
246
+→ Queue pending changes in Core Data
247
+→ Retry at next network change (observe URLSession events)
248
+```
249
+
250
+### CloudKit quota errors
251
+
252
+```
253
+Error: Quote exceeded (too many records)
254
+→ Log error
255
+→ Notify user: "iCloud storage full"
256
+→ Option: Archive old charge records
257
+```
258
+
259
+### Merge conflicts
260
+
261
+```
262
+Error: Conflict detected
263
+→ Auto-resolve via "last write wins"
264
+→ Log both versions for debugging
265
+→ Notify user if significant loss
266
+```
267
+
268
+## Dependencies
269
+
270
+- `NSPersistentCloudKitContainer`: from CloudKit framework
271
+- `CloudDeviceSettingsStore`: wrapper in AppData.swift
272
+- `CKModel.xcdatamodeld`: Core Data schema
273
+- `NSUbiquitousKeyValueStore`: legacy (deprecated)
274
+
275
+## References
276
+
277
+- Commit: [Fix CloudKit sync (Mar 2025)](https://github.com/...)
278
+- Documentaţie: [Project Memory - Fix CloudKit sync](../../MEMORY.md)
279
+- Issue: [[001 Catalyst TabView Freeze]]
+521 -0
Documentation/API Reference/ConsumptionMeasurement.md
@@ -0,0 +1,521 @@
1
+# Consumption Measurement Operation
2
+
3
+Măsurarea consumului pe o perioadă arbitrară, cu statistici şi predicţii bazate pe interval selectat.
4
+
5
+## Responsabilități
6
+
7
+- Măsurare consum liber (orice interval, nu doar sesiuni complete)
8
+- Afişare statistici pe interval selectat
9
+- Generare predicţii (extrapolări pe bază de trend)
10
+- Tăiere curbei ("tail trimming") pentru a selecta porţiunea relevantă
11
+- Salvează statisticile, NU datele măsurătorilor brute
12
+
13
+## Diferenţa faţă de Charging Monitoring
14
+
15
+| Aspect | Charging Monitoring | Consumption Measurement |
16
+|---|---|---|
17
+| Interval | Fixed (start → end charge) | Arbitrary (user-selected) |
18
+| Storage | Sesiune completă + measurements | Statistics only |
19
+| UI | Timeline sesiune | Graph with selection |
20
+| Predicţii | Capacity learning | Energy extrapolation |
21
+
22
+## Concepte
23
+
24
+### Consumption = Energy over time
25
+
26
+```
27
+Energy (Wh) = ∫ P(t) dt
28
+            = ∑ (V × A × Δt)
29
+
30
+Power (W) = V × A (instantaneous)
31
+Average Power (W) = Total Energy / Duration
32
+```
33
+
34
+## Invarianţi
35
+
36
+- **MUST**: `startTime < endTime` (interval valid)
37
+- **MUST**: Interval ≥ 10 secunde (minim useful)
38
+- **MUST**: Interval ≤ 30 zile (limit practic pentru extrapolări)
39
+- **MUST**: Statisticile trebuie salvate, măsurătorile brute se discard
40
+- **MUST**: Fără măsurători individuale în persistență, doar agregări
41
+- **SHOULD**: Interval tipic: 1 minut — 24 ore
42
+- **MAY**: Predicţii valide doar pe ±1 interval (ex: dacă measured 1h, predict next 1h)
43
+
44
+## Lifecycle
45
+
46
+### 1. Selection interval (user interaction)
47
+
48
+Utilizatorul deschide graficul şi selectează interval:
49
+
50
+```
51
+User taps start position în grafic
52
+User drags to end position
53
+System highlights selected region
54
+User taps "Measure consumption"
55
+```
56
+
57
+**Rezultat:**
58
+```swift
59
+struct ConsumptionSelection {
60
+    let startTime: Date
61
+    let endTime: Date
62
+    let meter: Meter
63
+    let device: ChargedDevice?  // Optional, may be unknown
64
+}
65
+```
66
+
67
+**MUST:** Times trebuie din măsurători existente (bounded)
68
+
69
+### 2. Compute statistics
70
+
71
+```swift
72
+func computeConsumptionStats(
73
+    measurements: [Measurement],
74
+    from startTime: Date,
75
+    to endTime: Date
76
+) -> ConsumptionStats {
77
+    // 1. Filter measurements în interval
78
+    let filtered = measurements.filter { m in
79
+        m.timestamp >= startTime && m.timestamp <= endTime
80
+    }
81
+    
82
+    guard !filtered.isEmpty else {
83
+        return .empty
84
+    }
85
+    
86
+    // 2. Calculate energy
87
+    let energy = calculateEnergy(filtered)
88
+    
89
+    // 3. Calculate time-based metrics
90
+    let duration = endTime.timeIntervalSince(startTime)
91
+    let averagePower = energy * 3600 / duration  // Convert Wh to W
92
+    
93
+    // 4. Power statistics
94
+    let powers = filtered.map { $0.power }
95
+    let peakPower = powers.max() ?? 0
96
+    let minPower = powers.min() ?? 0
97
+    let stdDev = calculateStdDev(powers)
98
+    
99
+    // 5. Voltage/Current stats
100
+    let voltages = filtered.map { $0.voltage }
101
+    let currents = filtered.map { $0.current }
102
+    
103
+    return ConsumptionStats(
104
+        energy: energy,
105
+        duration: duration,
106
+        averagePower: averagePower,
107
+        peakPower: peakPower,
108
+        minPower: minPower,
109
+        stdDevPower: stdDev,
110
+        sampleCount: filtered.count,
111
+        startVoltage: filtered.first?.voltage,
112
+        endVoltage: filtered.last?.voltage,
113
+        averageVoltage: voltages.average(),
114
+        averageCurrent: currents.average()
115
+    )
116
+}
117
+```
118
+
119
+### 3. Generate predictions
120
+
121
+```swift
122
+func predictFutureConsumption(
123
+    stats: ConsumptionStats,
124
+    duration: TimeInterval
125
+) -> ConsumptionPrediction {
126
+    // Linear extrapolation
127
+    let predictedEnergy = stats.averagePower * duration / 3600
128
+    
129
+    // Confidence bounds (±20% for stability)
130
+    let confidence = 0.8
131
+    let lowerBound = predictedEnergy * (1 - (1 - confidence))
132
+    let upperBound = predictedEnergy * (1 + (1 - confidence))
133
+    
134
+    return ConsumptionPrediction(
135
+        energy: predictedEnergy,
136
+        lowerBound: lowerBound,
137
+        upperBound: upperBound,
138
+        confidence: confidence,
139
+        method: "linear"
140
+    )
141
+}
142
+```
143
+
144
+**Metode:**
145
+- **Linear**: `E_predicted = P_avg × t` (simple, stable)
146
+- **Polynomial**: `E_predicted = a + b×t + c×t²` (for trends)
147
+- **Exponential**: (rarely used, for battery degradation)
148
+
149
+**MUST:** Confidence ≤ 1.0 (0-100%)
150
+**SHOULD:** Linear enough for 1 interval ahead
151
+**MAY:** Polynomial dacă > 2 intervals
152
+
153
+### 4. Tail trimming (user action)
154
+
155
+Utilizatorul poate "tăia coada" pentru a refina selecţia:
156
+
157
+```
158
+Initial selection: 00:00 - 10:00 (10h)
159
+User drags start: 00:30 - 10:00 (9.5h)
160
+User drags end: 00:30 - 09:30 (9h)
161
+Result: statistics recalculate pe [00:30, 09:30]
162
+```
163
+
164
+**Interactiv:**
165
+- Slider @ start time
166
+- Slider @ end time
167
+- Real-time stats update
168
+
169
+**MUST:** Recalculate stats on every trim
170
+**SHOULD:** Debounce updates (100ms)
171
+**MUST:** Store final selection, not intermediate states
172
+
173
+### 5. Save consumption record
174
+
175
+**NU salvează:** Măsurătorile individuale
176
+
177
+**SALVEAZĂ:**
178
+```swift
179
+struct ConsumptionRecord {
180
+    let id: UUID
181
+    let meterID: UUID
182
+    let deviceID: UUID?
183
+    let startTime: Date
184
+    let endTime: Date
185
+    
186
+    // Statistics (aggregated)
187
+    let totalEnergy: Double  // Wh
188
+    let averagePower: Double  // W
189
+    let peakPower: Double
190
+    let minPower: Double
191
+    let stdDevPower: Double
192
+    let sampleCount: Int
193
+    
194
+    // Metadata
195
+    let createdAt: Date
196
+    let notes: String?  // User annotation
197
+}
198
+```
199
+
200
+**NOT saved:**
201
+- ❌ Individual measurements (voltage, current per sample)
202
+- ❌ Timestamps of each sample
203
+- ❌ Raw power values
204
+
205
+**REASON:** Storage efficiency + privacy
206
+
207
+## API Public
208
+
209
+### Selection & Stats
210
+
211
+```swift
212
+// User selects interval from graph
213
+func selectConsumptionInterval(
214
+    start: Date,
215
+    end: Date,
216
+    meter: Meter
217
+) -> ConsumptionStats?
218
+
219
+// Refine selection via tail trimming
220
+func trimConsumptionInterval(
221
+    from newStart: Date,
222
+    to newEnd: Date
223
+) -> ConsumptionStats?
224
+
225
+// Get current statistics
226
+func currentStats() -> ConsumptionStats?
227
+```
228
+
229
+### Predictions
230
+
231
+```swift
232
+// Predict next interval
233
+func predictConsumption(
234
+    for duration: TimeInterval
235
+) -> ConsumptionPrediction?
236
+
237
+// Extrapolate to full day/week
238
+func extrapolateToDay() -> ConsumptionPrediction?
239
+func extrapolateToWeek() -> ConsumptionPrediction?
240
+```
241
+
242
+### Persistence
243
+
244
+```swift
245
+// Save aggregated stats only
246
+func saveConsumptionRecord(_ record: ConsumptionRecord) -> Bool
247
+
248
+// Load history
249
+func loadConsumptionRecords(for meter: Meter) -> [ConsumptionRecord]
250
+
251
+// Discard raw data (keep stats)
252
+func discardMeasurements(before: Date) -> Int
253
+```
254
+
255
+## Comportamente critice
256
+
257
+### Interval validation
258
+
259
+```
260
+User selects: 10:00 AM - 10:00 AM (zero duration)
261
+⟹ Error: "Minimum 10 seconds"
262
+
263
+User selects: 01-May - 01-June (31 days)
264
+⟹ Warning: "Extrapolation unreliable >7 days"
265
+```
266
+
267
+**MUST:** duration ≥ 10s
268
+**SHOULD:** Warn if duration > 7 days
269
+**SHOULD:** Cap predictions to ±1 interval
270
+
271
+### No tail = invalid
272
+
273
+```
274
+Selection: 10:00-11:00
275
+User never presses "Save"
276
+App terminates
277
+⟹ Selection lost (no persistence of in-progress)
278
+```
279
+
280
+**MUST:** NU salvează interim selections
281
+**SHOULD:** Keep in-memory during session
282
+**MUST:** Explicit save() required
283
+
284
+### Power variability
285
+
286
+```
287
+Measuring USB charger:
288
+Min power: 0.1W (idle)
289
+Max power: 18W (fast charge)
290
+StdDev: high (~5W)
291
+
292
+Prediction: linear avg = 8W
293
+Confidence: 0.7 (high variance)
294
+```
295
+
296
+**SHOULD:** Calculate stdDev
297
+**SHOULD:** Reflect confidence (lower if high variance)
298
+**MAY:** Suggest "Power unstable, prediction unreliable"
299
+
300
+### Measurement gaps
301
+
302
+```
303
+Recording: 10:00-11:00
304
+Meter disconnect: 10:30-10:45
305
+Data available: 10:00-10:30 (30min) + 10:45-11:00 (15min)
306
+User selects: 10:00-11:00 (but only 45min data)
307
+
308
+Stats: calculate on available data
309
+Warning: "15 minutes missing"
310
+```
311
+
312
+**MUST:** Alert user to gaps
313
+**SHOULD:** Calculate on available data only
314
+**MAY:** Adjust confidence (lower if gaps > 10%)
315
+
316
+## Statistici generate
317
+
318
+### Basic
319
+
320
+| Metric | Unit | Formula |
321
+|---|---|---|
322
+| Total Energy | Wh | ∑(V × A × Δt) |
323
+| Duration | seconds | endTime - startTime |
324
+| Average Power | W | Energy × 3600 / Duration |
325
+| Peak Power | W | max(P(t)) |
326
+| Min Power | W | min(P(t)) |
327
+
328
+### Advanced
329
+
330
+| Metric | Unit | Descriere |
331
+|---|---|---|
332
+| StdDev Power | W | Variabilitate în putere |
333
+| Avg Voltage | V | Media tensiune |
334
+| Avg Current | A | Media curent |
335
+| Sample Count | # | Measurement count |
336
+| Confidence | % | Reliability predicţie |
337
+
338
+## Predicţii
339
+
340
+### Linear extrapolation
341
+
342
+```
343
+Measured: P_avg = 10W over 1 hour
344
+Predict next 1h: E = 10W × 1h = 10Wh
345
+Bounds: ±(1-confidence)% = ±20% = [8Wh, 12Wh]
346
+```
347
+
348
+### Time scaling
349
+
350
+```
351
+Measured: 5W average over 10 minutes
352
+Predict over 1 hour: 5W × 6 = 30Wh
353
+Predict over 24h: 5W × 144 = 720Wh
354
+
355
+But confidence decreases with time:
356
+- 1×interval: 80% confidence
357
+- 2×interval: 60% confidence
358
+- 5×interval: 40% confidence
359
+- 10+×interval: 20% confidence
360
+```
361
+
362
+**MUST:** Confidence degrades dengan scaling
363
+**SHOULD:** Warn if confidence < 50%
364
+
365
+## Testare
366
+
367
+### Unit tests
368
+
369
+```swift
370
+test_selectConsumptionInterval_ValidDates()
371
+test_selectConsumptionInterval_MinimumDuration()
372
+test_trimConsumptionInterval_UpdatesStats()
373
+test_computeConsumptionStats_ValidData()
374
+test_computeConsumptionStats_EmptyRange()
375
+test_calculateAveragePower()
376
+test_predictConsumption_Linear()
377
+test_predictConsumption_ConfidenceDegrades()
378
+test_extrapolateToDay_WithinBounds()
379
+test_recordDoesNotStoreMeasurements()
380
+test_discardMeasurements_KeepsStats()
381
+```
382
+
383
+### Integration tests
384
+
385
+- [ ] Select 1h interval → get stats
386
+- [ ] Trim tail → stats recalculate
387
+- [ ] Predict next 1h → confidence 80%
388
+- [ ] Predict next 24h → confidence drops to 40%
389
+- [ ] Save record → measurements discarded, stats kept
390
+- [ ] Load record → only stats available
391
+- [ ] Measurement gap detected → warning shown
392
+- [ ] High power variance → confidence reduced
393
+
394
+## Edge cases
395
+
396
+### Very short interval
397
+
398
+```
399
+User selects: 10:00:00 - 10:00:05 (5 seconds)
400
+⟹ Error: below 10s minimum
401
+```
402
+
403
+**MUST:** Reject
404
+
405
+### Very long interval
406
+
407
+```
408
+User selects: 01-Jan - 31-Dec (365 days)
409
+⟹ Warning: "Extrapolation unreliable beyond 7 days"
410
+Confidence: 20%
411
+```
412
+
413
+**SHOULD:** Accept but warn
414
+**SHOULD:** Reduce confidence
415
+
416
+### Single measurement
417
+
418
+```
419
+Interval: 10:00:00 - 10:00:01
420
+Data points: 1 (only start)
421
+Energy: 0 (insufficient interval)
422
+⟹ Error: "Need minimum 10 measurements"
423
+```
424
+
425
+**MUST:** Require ≥ 10 samples
426
+
427
+### No voltage/current data
428
+
429
+```
430
+Meter type: Unknown or not reporting
431
+Power available: yes
432
+Voltage/Current: null
433
+
434
+Stats: calculate energy from power only
435
+Avg Voltage: null
436
+Avg Current: null
437
+```
438
+
439
+**SHOULD:** Handle gracefully
440
+**MAY:** Show "Limited data available"
441
+
442
+## Storage model
443
+
444
+### What IS saved
445
+
446
+```json
447
+{
448
+  "id": "UUID",
449
+  "meterID": "UUID",
450
+  "startTime": "2026-05-23T10:00:00Z",
451
+  "endTime": "2026-05-23T11:00:00Z",
452
+  "totalEnergy": 12.5,
453
+  "averagePower": 12.5,
454
+  "peakPower": 18.0,
455
+  "minPower": 1.2,
456
+  "stdDevPower": 4.5,
457
+  "sampleCount": 3600,
458
+  "createdAt": "2026-05-23T11:02:00Z"
459
+}
460
+```
461
+
462
+### What is NOT saved
463
+
464
+```
465
+❌ individual measurements
466
+❌ voltage samples
467
+❌ current samples  
468
+❌ power curve
469
+❌ battery levels
470
+❌ meter metadata
471
+```
472
+
473
+## Dependenţe
474
+
475
+- [Charging Monitoring](./ChargingMonitoring.md): access to meter data
476
+- [Charge Curve Storage](./ChargeCurveStorage.md): query full measurements
477
+- Core Data: ConsumptionRecord entity
478
+- UI: Graph view with interval selection
479
+
480
+## UI Components
481
+
482
+### Graph with selection
483
+
484
+```
485
+┌─────────────────────────────────┐
486
+│  Power (W)                      │
487
+│  20 ├──────┐                    │
488
+│     │ ████ │  ← user selection  │
489
+│  10 ├──████└────┐               │
490
+│     │          │               │
491
+│   0 └──────────┴───────────────┘
492
+│     10:00   10:30    11:00
493
+│     ↑start           ↑end
494
+│     (draggable)      (draggable)
495
+└─────────────────────────────────┘
496
+```
497
+
498
+### Stats display
499
+
500
+```
501
+Selected Interval: 10:00 — 10:30 (30 min)
502
+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
503
+Energy:         6.3 Wh
504
+Average Power:  12.6 W
505
+Peak Power:     18.0 W
506
+Min Power:      1.2 W
507
+Variance:       ±4.5 W
508
+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
509
+Prediction (next 30 min):
510
+  Energy:       6.3 Wh (confidence: 80%)
511
+  Range:        5.0 — 7.6 Wh
512
+```
513
+
514
+## Notes
515
+
516
+- **Not a charge session**: No device pairing, no capacity learning
517
+- **Independent operation**: Coexists with ChargeInsights
518
+- **Low storage footprint**: Only aggregates, not raw curves
519
+- **Privacy-friendly**: Discards detailed measurements
520
+- **Real-time**: Updates on interval selection/trimming
521
+- Related: [Charging Monitoring](./ChargingMonitoring.md) for full sessions
+590 -0
Documentation/API Reference/IdleConsumptionMeasurement.md
@@ -0,0 +1,590 @@
1
+# Idle Consumption Measurement Operation
2
+
3
+Măsurarea consumului rezidual (standby) al unui dispozitiv încărcat la maxim.
4
+
5
+## Responsabilități
6
+
7
+- Determinarea consumului inactiv real al unui dispozitiv (după 100% + top-up)
8
+- Detectare automată a stării "idle" (power stabilizat)
9
+- Selectare manuală a intervalului idle din grafic
10
+- Salvare profil idle per device
11
+- Utilizare pentru terminare automată sesiune + bilanț energetic
12
+
13
+## Context
14
+
15
+### Problema
16
+
17
+După ce dispozitivul raportează **100% battery**, încărcătorul nu se opreşte imediat:
18
+
19
+```
20
+Timeline:
21
+00:00 — Device reaches 100%
22
+00:00-00:15 — Top-up phase (unele devices încarcă semnificativ)
23
+               Current: 1.5A → 0.8A → 0.3A → 0.05A
24
+00:15 — Top-up terminat, device intra în idle
25
+00:15-∞ — Idle phase (consum rezidual constant)
26
+         Current: 0.05A constant
27
+         Power: V × 0.05A ≈ 0.25W
28
+```
29
+
30
+**Necesar:**
31
+- Distinge top-up phase de idle phase
32
+- Măsoară idle power real
33
+- Folosește-o pentru bilanț energetic final
34
+
35
+### Consumul idle = baseline pentru bilanț
36
+
37
+```
38
+Total energy from charger:      50 Wh (measured)
39
+Time charging:                  2 hours
40
+Idle power:                      0.25 W
41
+Energy to idle consumption:     0.25W × 2h = 0.5 Wh
42
+
43
+Real energy to device battery: 50 - 0.5 = 49.5 Wh
44
+
45
+⟹ Capacity learning mai precis
46
+⟹ Bilanț charger vs device vs loss
47
+```
48
+
49
+## Invarianţi
50
+
51
+- **MUST**: Device la 100% battery la start
52
+- **MUST**: Idle power < 5W (nu active usage)
53
+- **MUST**: Idle power > 0.01W (detectabil)
54
+- **MUST**: Idle interval ≥ 30 secunde (stabilitate)
55
+- **MUST**: Idle power salvat per (device, charger type)
56
+- **SHOULD**: Idle consumul constant (variance < 10%)
57
+- **MAY**: Idle profile salvat in Core Data
58
+
59
+## Lifecycle
60
+
61
+### 1. Setup & initialization
62
+
63
+Utilizatorul:
64
+1. Încarcă device la maxim (100%)
65
+2. Lasă conectat la charger după 100%
66
+3. Deschide app → "Measure idle consumption"
67
+4. Selectează dispozitiv + charger type
68
+5. Pornește măsurare
69
+
70
+```swift
71
+struct IdleConsumptionSession {
72
+    let id: UUID
73
+    let deviceID: UUID
74
+    let chargerType: ChargerType?
75
+    let startTime: Date
76
+    let measuredAt100Percent: Bool  // MUST: true
77
+    let sessionID: UUID
78
+}
79
+```
80
+
81
+**Precondition:**
82
+- Device must report 100% battery
83
+- Device connected to charger
84
+- Meter recording power
85
+
86
+### 2. Wait for top-up completion
87
+
88
+App observă power trend:
89
+
90
+```swift
91
+func detectIdlePhase(measurements: [Measurement]) -> Int? {
92
+    // Last 60 measurements (1 minute)
93
+    let recent = measurements.suffix(60)
94
+    
95
+    // Calculate power variance in recent window
96
+    let powers = recent.map { $0.power }
97
+    let mean = powers.reduce(0, +) / Double(powers.count)
98
+    let variance = powers.map { pow($0 - mean, 2) }.reduce(0, +) / Double(powers.count)
99
+    let stdDev = sqrt(variance)
100
+    
101
+    // Idle criteria:
102
+    // 1. Power < 5W (no active charging)
103
+    // 2. Variance < 10% of mean (stable)
104
+    // 3. Duration > 30s at this level
105
+    
106
+    if mean < 5.0 && stdDev < (mean * 0.1) {
107
+        return measurements.count - 60  // Index of phase start
108
+    }
109
+    return nil
110
+}
111
+```
112
+
113
+**Detection criteria:**
114
+- Power < 5W (no active charging)
115
+- StdDev < 10% of mean (stable, not fluctuating)
116
+- Duration > 30 seconds (confirm stability)
117
+
118
+**Timeline:**
119
+```
120
+10:00 — User starts measurement
121
+10:15 — Top-up ends, power drops to 0.25W
122
+        App auto-detects idle phase
123
+10:15-10:45 — User reviews, confirms
124
+```
125
+
126
+**MUST**: Detect automatically when possible
127
+**SHOULD**: Notify user "Idle phase detected @ 10:15"
128
+
129
+### 3. User review & selection
130
+
131
+User vede graficul cu highlight pentru idle phase detectată:
132
+
133
+```
134
+Power (W)
135
+20 ├───────────────────┐
136
+   │ Top-up phase      │
137
+10 ├───────────┐       │
138
+   │           │       │
139
+ 5 ├───────────┤       │
140
+   │  ↓ Idle detected   │
141
+ 0.5 ├───────────────┐ ◄─ Auto-selected interval
142
+   │                │
143
+   └────┬──────────┬──┴───────────→ time
144
+        10:15      10:45
145
+    (start idle)   (user confirmed)
146
+```
147
+
148
+**User actions:**
149
+- Tap to confirm auto-selected interval
150
+- Drag to refine start position
151
+- Drag to refine end position
152
+- Tap "Save idle profile"
153
+
154
+**MUST**: Allow manual override
155
+**SHOULD**: Offer auto-selection if confidence high
156
+
157
+### 4. Compute idle power
158
+
159
+```swift
160
+func computeIdlePower(
161
+    measurements: [Measurement],
162
+    from startTime: Date,
163
+    to endTime: Date
164
+) -> IdleConsumptionProfile {
165
+    let filtered = measurements.filter { m in
166
+        m.timestamp >= startTime && m.timestamp <= endTime
167
+    }
168
+    
169
+    guard !filtered.isEmpty else { return nil }
170
+    
171
+    let powers = filtered.map { $0.power }
172
+    let currents = filtered.map { $0.current }
173
+    let voltages = filtered.map { $0.voltage }
174
+    
175
+    let avgPower = powers.reduce(0, +) / Double(powers.count)
176
+    let stdDev = sqrt(
177
+        powers.map { pow($0 - avgPower, 2) }.reduce(0, +) / Double(powers.count)
178
+    )
179
+    
180
+    // Energy over idle period
181
+    let duration = endTime.timeIntervalSince(startTime)
182
+    let energy = avgPower * duration / 3600  // Wh
183
+    
184
+    return IdleConsumptionProfile(
185
+        deviceID: device.id,
186
+        chargerID: charger?.id,
187
+        idlePower: avgPower,  // W
188
+        stdDev: stdDev,
189
+        energy: energy,
190
+        sampleCount: filtered.count,
191
+        measuredAt: Date.now,
192
+        interval: (startTime, endTime)
193
+    )
194
+}
195
+```
196
+
197
+### 5. Save idle profile
198
+
199
+**Salvează:**
200
+```swift
201
+struct IdleConsumptionProfile {
202
+    let id: UUID
203
+    let deviceID: UUID
204
+    let chargerID: UUID?  // Optional, may be unknown charger
205
+    let idlePower: Double  // W, avg
206
+    let stdDev: Double     // W, variance
207
+    let energy: Double     // Wh, cumulative idle energy
208
+    let sampleCount: Int
209
+    let measuredAt: Date
210
+    let interval: (start: Date, end: Date)
211
+    let notes: String?
212
+}
213
+```
214
+
215
+**NU salvează:** Raw measurements
216
+
217
+**Storage:** Core Data `DeviceIdleProfile` table
218
+
219
+**MUST**: Salvare explicită (user confirm)
220
+**SHOULD**: Allow update (measure again if suspicious)
221
+
222
+## API Public
223
+
224
+### Measurement
225
+
226
+```swift
227
+// Start idle measurement session
228
+func startIdleConsumptionSession(
229
+    for device: ChargedDevice,
230
+    charger: ChargerType?
231
+) -> IdleConsumptionSession
232
+
233
+// Detect idle phase automatically
234
+func detectIdlePhase(
235
+    in measurements: [Measurement]
236
+) -> (startIndex: Int, confidence: Double)?
237
+
238
+// Compute idle profile for interval
239
+func computeIdlePower(
240
+    measurements: [Measurement],
241
+    from: Date,
242
+    to: Date
243
+) -> IdleConsumptionProfile?
244
+
245
+// Save profile
246
+func saveIdleProfile(_ profile: IdleConsumptionProfile) -> Bool
247
+```
248
+
249
+### Query & usage
250
+
251
+```swift
252
+// Get idle profile for device
253
+func loadIdleProfile(for device: ChargedDevice) -> IdleConsumptionProfile?
254
+
255
+// Get all profiles for device (multiple charger types)
256
+func loadIdleProfiles(for device: ChargedDevice) 
257
+    -> [IdleConsumptionProfile]
258
+
259
+// Get idle power estimate for device+charger combo
260
+func getIdlePower(
261
+    device: ChargedDevice,
262
+    charger: ChargerType?
263
+) -> Double?
264
+
265
+// Update profile after new measurement
266
+func updateIdleProfile(_ profile: IdleConsumptionProfile) -> Bool
267
+
268
+// Delete old profile
269
+func deleteIdleProfile(_ id: UUID) -> Bool
270
+```
271
+
272
+## Comportamente critice
273
+
274
+### Top-up phase confusion
275
+
276
+```
277
+Timeline misreading:
278
+User thinks 10:10 = idle start
279
+But device still trickling charge @ 0.2A
280
+Power = 1.0W (not idle yet!)
281
+⟹ False idle profile
282
+```
283
+
284
+**MUST**: Require variance < 10% (not just low power)
285
+**SHOULD**: Alert user if variance high
286
+**MAY**: Auto-suggest earlier/later boundary
287
+
288
+### Multiple charger types
289
+
290
+```
291
+Device A measured with:
292
+- Charger X: idle = 0.25W
293
+- Charger Y: idle = 0.30W (different charger)
294
+
295
+⟹ Store separate profiles
296
+⟹ User selects charger type at measurement time
297
+```
298
+
299
+**MUST**: Key by (device, charger type)
300
+**SHOULD**: Allow null charger (unknown)
301
+**SHOULD**: Group by device in UI
302
+
303
+### Device still charging (false idle)
304
+
305
+```
306
+User hits "save" while device still @ 100%
307
+But charger delivering 0.5A (top-up)
308
+Power = 2.5W
309
+⟹ This is NOT idle, it's slow charge!
310
+```
311
+
312
+**MUST**: Warn if battery % still 100% during interval
313
+**SHOULD**: Reject if power > 3W (too high for idle)
314
+**MAY**: Ask "Are you sure this is idle?"
315
+
316
+### Idle profile very high (> 1W)
317
+
318
+```
319
+Device idle measurement: 1.5W
320
+(Typical idle: 0.1-0.5W)
321
+⟹ Something unusual:
322
+   - Screen still on?
323
+   - Background app running?
324
+   - Device malfunctioning?
325
+```
326
+
327
+**SHOULD**: Warn "Idle power seems high"
328
+**SHOULD**: Suggest retry with device fully idle
329
+**MAY**: Store with `dataQuality: "warning"`
330
+
331
+## Use cases
332
+
333
+### 1. Auto-terminate charging session
334
+
335
+```
336
+Charging session started:
337
+  Start battery: 20%
338
+  Start energy: 0 Wh
339
+
340
+During charging:
341
+  Monitor power trend
342
+  When power drops to idle level...
343
+  
344
+Idle detection trigger:
345
+  Power < idle_threshold (e.g., 0.3W)
346
+  Duration > 2 minutes stable
347
+  Battery = 100%
348
+  
349
+⟹ Auto-terminate session
350
+⟹ Calculate final energy
351
+```
352
+
353
+**Pseudocode:**
354
+```swift
355
+func shouldAutoTerminateCharging(
356
+    currentPower: Double,
357
+    currentBattery: Double,
358
+    duration: TimeInterval
359
+) -> Bool {
360
+    let idleProfile = loadIdleProfile(for: device)
361
+    let idleThreshold = (idleProfile?.idlePower ?? 0.5) * 1.5  // 50% margin
362
+    
363
+    return currentPower < idleThreshold &&
364
+           currentBattery >= 99.0 &&
365
+           duration > 120  // 2 min stable
366
+}
367
+```
368
+
369
+### 2. Bilanț energetic final
370
+
371
+```
372
+Charging session:
373
+  Meter energy:      50.0 Wh
374
+  Duration:          2 hours
375
+  Idle power:        0.25 W
376
+  Idle energy:       0.25W × 2h = 0.5 Wh
377
+  
378
+Charger idle:        0.10 W
379
+Charger idle energy: 0.10W × 2h = 0.2 Wh
380
+
381
+Energy to device:    50.0 - 0.5 - 0.2 = 49.3 Wh
382
+```
383
+
384
+**Accuracy improvement**: +1-2% fidelity
385
+
386
+### 3. Anomaly detection
387
+
388
+```
389
+Device A (iPhone):
390
+  Normal idle: 0.15W
391
+  Today's measurement: 0.45W  (3× higher!)
392
+  
393
+⟹ Alert user:
394
+   "Device drawing 3× idle power"
395
+   "Battery drain issue?"
396
+   "Check background apps"
397
+```
398
+
399
+## Statistici generate
400
+
401
+| Metric | Unit | Descriere |
402
+|---|---|---|
403
+| Idle Power | W | Average power during idle |
404
+| StdDev | W | Variance (should be low) |
405
+| Idle Energy | Wh | Total over measurement period |
406
+| Sample Count | # | Measurements in interval |
407
+| Confidence | % | Stability metric |
408
+| Data Quality | flag | "good" / "warning" / "poor" |
409
+
410
+### Confidence calculation
411
+
412
+```swift
413
+func calculateConfidence(stdDev: Double, mean: Double) -> Double {
414
+    let relativeStdDev = stdDev / mean
415
+    
416
+    if relativeStdDev < 0.05 {
417
+        return 0.95  // Excellent
418
+    } else if relativeStdDev < 0.10 {
419
+        return 0.85  // Good
420
+    } else if relativeStdDev < 0.20 {
421
+        return 0.70  // Fair
422
+    } else {
423
+        return 0.50  // Poor (high variance)
424
+    }
425
+}
426
+```
427
+
428
+## Testare
429
+
430
+### Unit tests
431
+
432
+```swift
433
+test_detectIdlePhase_ValidData()
434
+test_detectIdlePhase_HighVariance_NotDetected()
435
+test_detectIdlePhase_HighPower_NotDetected()
436
+test_detectIdlePhase_MinimumDuration()
437
+test_computeIdlePower_ValidInterval()
438
+test_computeIdlePower_EmptyInterval()
439
+test_computeIdlePower_HighVariance_Warning()
440
+test_saveIdleProfile_Persists()
441
+test_loadIdleProfile_ByDevice()
442
+test_multipleChargerTypes_SeparateProfiles()
443
+test_shouldAutoTerminateCharging_WithIdleProfile()
444
+test_energyBilantz_SubtractsIdleEnergy()
445
+test_confidenceCalculation_RelativeVariance()
446
+```
447
+
448
+### Integration tests
449
+
450
+- [ ] Measure idle: device 100%, charger connected
451
+- [ ] Auto-detect idle phase when variance drops
452
+- [ ] User overrides auto-selection with manual interval
453
+- [ ] Save idle profile → reload → same values
454
+- [ ] Multiple profiles per device (different chargers)
455
+- [ ] Auto-terminate charging when idle detected
456
+- [ ] Energy bilanț matches meter - idle correction
457
+- [ ] Anomaly detection for 3× idle power
458
+
459
+## Edge cases
460
+
461
+### Very low idle (< 0.01W)
462
+
463
+```
464
+Device truly idle: 0.008W
465
+Below detection threshold
466
+⟹ Impossible to measure accurately
467
+```
468
+
469
+**SHOULD**: "Idle power too low to measure"
470
+**MAY**: Skip measurement
471
+
472
+### Device powered off during measurement
473
+
474
+```
475
+User powers off device mid-measurement
476
+Power drops to 0W
477
+⟹ Not idle, device is off
478
+```
479
+
480
+**MUST**: Detect power drop to 0
481
+**SHOULD**: Discard measurement
482
+**MAY**: Suggest "Check device is still on"
483
+
484
+### Charger unplugged mid-measurement
485
+
486
+```
487
+User unplugs charger
488
+Power drops immediately
489
+⟹ Not valid idle state
490
+```
491
+
492
+**SHOULD**: Detect sudden power drop
493
+**SHOULD**: Alert user "Charger disconnected?"
494
+
495
+### Very long idle measurement
496
+
497
+```
498
+User measures 24 hours of idle
499
+Device behavior may change:
500
+- OS updates background activity
501
+- Bluetooth periodic sync
502
+- GPS location update
503
+⟹ Not constant idle
504
+```
505
+
506
+**SHOULD**: Warn if > 2 hours
507
+**MAY**: Suggest shorter interval (30 min typical)
508
+
509
+## Storage & syncing
510
+
511
+### What IS saved
512
+
513
+```json
514
+{
515
+  "id": "UUID",
516
+  "deviceID": "UUID",
517
+  "chargerID": "UUID?",
518
+  "idlePower": 0.25,
519
+  "stdDev": 0.02,
520
+  "energy": 0.5,
521
+  "sampleCount": 3600,
522
+  "measuredAt": "2026-05-23T10:45:00Z",
523
+  "confidence": 0.85,
524
+  "dataQuality": "good"
525
+}
526
+```
527
+
528
+### What is NOT saved
529
+
530
+```
531
+❌ individual measurements
532
+❌ raw power samples
533
+❌ voltage/current timeseries
534
+❌ interval timestamps (only duration)
535
+```
536
+
537
+**Reason**: Storage efficiency
538
+
539
+### CloudKit sync
540
+
541
+- **SHOULD**: Sync idle profiles to iCloud
542
+- **REASON**: Use on other devices (same charger type)
543
+- **Example**: iPhone idle measured on device A, used on device B
544
+
545
+## Dependenţe
546
+
547
+- [Charging Monitoring](./ChargingMonitoring.md): session context
548
+- [Consumption Measurement](./ConsumptionMeasurement.md): power analysis
549
+- Core Data: `DeviceIdleProfile` entity
550
+- UI: Graph with selection + auto-detection highlight
551
+
552
+## Related features
553
+
554
+### Auto-terminate charging session
555
+
556
+```swift
557
+func shouldAutoTerminate(
558
+    session: ChargeSession,
559
+    currentPower: Double,
560
+    battery: Double
561
+) -> Bool {
562
+    guard battery >= 99.0 else { return false }
563
+    
564
+    let idle = loadIdleProfile(for: session.device)
565
+    let threshold = (idle?.idlePower ?? 0.5) * 1.5
566
+    
567
+    return currentPower < threshold
568
+}
569
+```
570
+
571
+### Energy balance correction
572
+
573
+```swift
574
+func correctedDeviceEnergy(
575
+    total: Double,
576
+    idleProfile: IdleConsumptionProfile,
577
+    duration: TimeInterval
578
+) -> Double {
579
+    let idleEnergy = idleProfile.idlePower * duration / 3600
580
+    return total - idleEnergy
581
+}
582
+```
583
+
584
+## Notes
585
+
586
+- **Measurement window**: Tipic 30 min — 2 ore
587
+- **Update frequency**: Every few weeks (device behavior stable)
588
+- **Per charger**: Different chargers may have different idle power
589
+- **Privacy**: No raw data, just aggregates
590
+- **Related**: [Charge Session Integrity](../Charge%20Session%20Integrity%20and%20Conflict%20Healing.md)
+152 -0
Documentation/API Reference/Meter.md
@@ -0,0 +1,152 @@
1
+# Meter Entity
2
+
3
+Reprezentarea unui contor Bluetooth conectat (UM25C, UM34C, TC66C).
4
+
5
+## Responsabilități
6
+
7
+- Modelarea unui dispozitiv USB power meter BT
8
+- Gestionarea stării de conectare şi comunicare
9
+- Citirea măsurătorilor de la dispozitiv
10
+- Persistență metadate conector (device, timestamp, expiry)
11
+
12
+## Invarianți
13
+
14
+- **MUST**: Un Meter are un `id` (UUID) unic în aplicaţie
15
+- **MUST**: Un Meter cu `macAddress` nu poate fi duplicat în Core Data (Cloud-Kit safe)
16
+- **MUST**: Starea `OperationalState` este monoton crescătoare: `notPresent → ... → dataIsAvailable`
17
+- **MUST**: Dacă `OperationalState` este `peripheralConnected` sau mai mare, trebuie să existe o conexiune BT activă
18
+- **SHOULD**: Un Meter inactiv (fără măsurători > 2 ore) ar trebui să se reconecteze automat
19
+- **MAY**: Temperature unit preference (Celsius/Fahrenheit) poate fi schimbat oricând
20
+
21
+## Estados operaţionale
22
+
23
+```
24
+notPresent
25
+  ↓
26
+peripheralNotConnected
27
+  ↓
28
+peripheralConnectionPending
29
+  ↓
30
+peripheralConnected
31
+  ↓
32
+peripheralReady
33
+  ↓
34
+comunicating ↔ dataIsAvailable
35
+```
36
+
37
+## API Public
38
+
39
+### Proprietăţi
40
+
41
+| Proprietate | Tip | Descriere | Observaţii |
42
+|---|---|---|---|
43
+| `id` | UUID | Identificator unic | Generat la creare |
44
+| `macAddress` | String | Adresa MAC BT | De ex: "AA:BB:CC:DD:EE:FF" |
45
+| `meter` | Model | Tipul: UM25C, UM34C, TC66C | Immutable după creare |
46
+| `meterName` | String | Nume ales de utilizator | Persistent în Core Data |
47
+| `operationalState` | OperationalState | Starea curentă | Published, triggers UI updates |
48
+| `lastDataPoint` | Measurement? | Ultima măsurătoare | Nullable |
49
+| `connectionMetadata` | ConnectionMetadata? | Info conexiune curentă | Device, timestamp, expiry |
50
+| `discoveryMetadata` | DiscoveryMetadata? | Info descoperire BT | Last seen, seen by |
51
+| `temperatureUnit` | TemperatureUnitPreference | Unitatei temperatură | Celsius/Fahrenheit |
52
+
53
+### Metode
54
+
55
+```swift
56
+// Conectare
57
+func connect() 
58
+// MUST: tranzițe starea la peripheralConnectionPending
59
+// SHOULD: se conectează în max 5s
60
+
61
+func disconnect()
62
+// MUST: tranzițe starea la peripheralNotConnected
63
+// MUST: eliberează resurse BT
64
+
65
+// Citire măsurători
66
+func requestMeasurement() -> Bool
67
+// SHOULD: retur true dacă cererea a fost trimisă
68
+// SHOULD: se așteaptă răspuns în max 3s (timeout)
69
+// MUST: nu trimite dacă starea este < comunicating
70
+
71
+func processMeasurementData(_ data: Data) -> Measurement?
72
+// Parsează payload-ul de la dispozitiv
73
+// MUST: retur nil dacă payload-ul e invalid
74
+// SHOULD: loghează checksum errors
75
+
76
+// Gestionare sesiuni
77
+func startChargeRecord(for device: ChargedDevice) -> ChargeRecord
78
+// Inițiază o nouă sesiune de încărcare
79
+
80
+func endChargeRecord(_ record: ChargeRecord)
81
+// Finalizează sesiunea
82
+
83
+// Naming
84
+func renameToMeter(_ newName: String)
85
+// MUST: actualizează proprietatea şi persistă în Core Data
86
+```
87
+
88
+## Comportamente critice
89
+
90
+### Reconexiune automată
91
+
92
+- **MUST**: Dacă `OperationalState < peripheralConnected`, retry-ează conectarea
93
+- **SHOULD**: Backoff exponential: 1s, 2s, 4s, 8s, max 60s
94
+- **SHOULD**: Anulează retry-urile dacă utilizatorul deconectează manual
95
+
96
+### Timeout pe măsurători
97
+
98
+- **MUST**: Dacă nu primim răspuns în 3s după cerere, timeout-ul măsurătorii
99
+- **SHOULD**: Loghează timeout-urile pentru debugging
100
+- **MAY**: Incrementează contorul de failed requests
101
+
102
+### Stare după disconnecţie accidentală
103
+
104
+- **MUST**: Dacă BT drop-ă accidental, starea revine la `peripheralNotConnected`
105
+- **SHOULD**: Încearcă reconexiune automată (backoff)
106
+- **MAY**: Notifică UI-ul cu banner "Meter disconnected"
107
+
108
+### Validare MAC address
109
+
110
+- **MUST**: MAC address trebuie să fie format valid (XX:XX:XX:XX:XX:XX)
111
+- **MUST**: Dacă MAC e invalid, Meter nu poate fi creat
112
+
113
+## Testare
114
+
115
+### Unit tests
116
+
117
+```swift
118
+// Stări operaţionale
119
+test_operationalStateTransition()
120
+test_stateMonotonicity()
121
+
122
+// Conectare
123
+test_connectInitiatesPeripheralConnectionPending()
124
+test_disconnectCleansUpResources()
125
+test_reconnectWithBackoff()
126
+
127
+// Măsurători
128
+test_requestMeasurementFailsIfStateInvalid()
129
+test_processMeasurementDataWithValidPayload()
130
+test_measurementTimeout()
131
+
132
+// Naming
133
+test_renameUpdatesPersistence()
134
+```
135
+
136
+### Integration tests
137
+
138
+- [ ] Meter apare în Sidebar după scan
139
+- [ ] Meter se reconectează după BT drop
140
+- [ ] Măsurătorile se salvează pentru ChargedDevice
141
+- [ ] Temperature unit e persistent între app restarts
142
+
143
+## Dependenţe
144
+
145
+- `BluetoothManager`: gestionează Core Bluetooth
146
+- `ChargeInsightsStore`: salvează măsurătorile
147
+- `AppData`: CloudKit sync
148
+
149
+## Note
150
+
151
+- Legat: [CloudKit Sync](./CloudKitSync.md) (persistență MAC, name)
152
+- Legat: [Bluetooth Discovery](./BluetoothDiscovery.md) (scan, advertisement)
+249 -0
Documentation/API Reference/Operations.md
@@ -0,0 +1,249 @@
1
+# Core Operations
2
+
3
+Operaţii principale care orchestrează aplicaţia.
4
+
5
+## AppData Lifecycle
6
+
7
+`AppData` este singleton-ul global care orchestrează toate subsistemele.
8
+
9
+### Inițializare
10
+
11
+```swift
12
+let appData = AppData()
13
+```
14
+
15
+- **MUST**: Se instanțiază în `AppDelegate.application(_:didFinishLaunchingWithOptions:)`
16
+- **MUST**: Se accesează din `SceneDelegate.scene(_:willConnectTo:options:)`
17
+- **MUST**: Inițializează `NSPersistentCloudKitContainer` cu name `"CKModel"`
18
+- **MUST**: Inițializează `CloudDeviceSettingsStore` (wraps NSManagedObjectContext)
19
+
20
+### Startup sequence
21
+
22
+1. `AppDelegate.application(_:didFinishLaunchingWithOptions:)`:
23
+   - Crează `AppData()`
24
+   - Inițializează Core Data stack
25
+   - Încarcă migrări din `cloudStoreRebuildVersion`
26
+
27
+2. `SceneDelegate.scene(_:willConnectTo:options:)`:
28
+   - Apelează `appData.activateCloudDeviceSync(context: sceneContext)`
29
+   - Inițiază BT scanning
30
+   - Inițiază CloudKit sync
31
+
32
+### Shutdown sequence
33
+
34
+- **SHOULD**: Finalizează sesiuni active la app terminate
35
+- **MUST**: Salvează Core Data context (`saveIfNeeded()`)
36
+- **SHOULD**: Deconectează BT graceful
37
+
38
+## Bluetooth Connection Management
39
+
40
+### Connection state machine
41
+
42
+```
43
+notPresent
44
+  ↓
45
+peripheralNotConnected ← (user disconnect)
46
+  ↓
47
+peripheralConnectionPending
48
+  ↓
49
+peripheralConnected
50
+  ↓
51
+peripheralReady
52
+  ↓
53
+comunicating ↔ dataIsAvailable
54
+```
55
+
56
+### Operations
57
+
58
+```swift
59
+// Conectare inițială
60
+blutooth.connect(to peripheralUUID: UUID, type: Model)
61
+// MUST: inițiază CBCentralManager scan
62
+// MUST: se conectează la UUID specific
63
+// SHOULD: timeout = 5s
64
+// MUST: pe succes, tranzițe la peripheralConnected
65
+
66
+// Deconectare
67
+bluetooth.disconnect(from meter: Meter)
68
+// MUST: anulează reconnect logic
69
+// MUST: eliberează resurse
70
+// MUST: marchez ca manual disconnect
71
+
72
+// Auto-reconnect
73
+bluetooth.autoReconnect(meter: Meter, backoff: Backoff)
74
+// SHOULD: exponential backoff: 1s, 2s, 4s, 8s, max 60s
75
+// MUST: anulează dacă utilizator disconnect manual
76
+// MUST: max 3 retry-uri consecutive
77
+```
78
+
79
+## Measurement Recording
80
+
81
+### Session lifecycle
82
+
83
+```swift
84
+let session = meter.startChargeRecord(for: device)
85
+```
86
+
87
+1. **Start**: Crează `ChargeRecord(sessionID: UUID(), startTime: now())`
88
+2. **Measure**: La fiecare 1-2s:
89
+   ```swift
90
+   let measurement = meter.lastDataPoint
91
+   session.addMeasurement(measurement)
92
+   ```
93
+3. **End**: 
94
+   ```swift
95
+   meter.endChargeRecord(session)
96
+   ```
97
+   - Calculează `totalEnergy = ∑(V * A * Δt)`
98
+   - Marchez ca `completed`
99
+   - Salvează în Core Data
100
+
101
+### Invarianţi
102
+
103
+- **MUST**: O sesiune activă per meter
104
+- **MUST**: Măsurătorile sunt cronologice
105
+- **MUST**: `startTime <= now() <= (endTime || ∞)`
106
+- **MUST**: Energia ≥ 0
107
+
108
+### Recording frequency
109
+
110
+- **SHOULD**: Măsurători la ~1Hz (1000ms interval)
111
+- **MAY**: Reduce frequency dacă battery low
112
+- **SHOULD**: Drop măsurători dacă queue > 100 items
113
+
114
+## Cloud Sync
115
+
116
+### Main concepts
117
+
118
+- **DeviceSettings**: entitate Core Data persistă MAC, name, temperature unit
119
+- **CloudDeviceSettingsStore**: wrapper pe NSManagedObjectContext
120
+- **Rebuild version**: `cloudStoreRebuildVersion` (curent: 3)
121
+
122
+### Sync flow
123
+
124
+1. **Upload**: Locale changes → Core Data → CloudKit
125
+   ```swift
126
+   cloudStore.upsertDeviceSettings(
127
+       macAddress: "AA:BB:CC:DD:EE:FF",
128
+       meterName: "Kitchen Meter",
129
+       tc66TemperatureUnit: .celsius,
130
+       connectionMetadata: ...
131
+   )
132
+   ```
133
+   - MUST: salvează în Core Data sync
134
+   - MUST: CloudKit container replica-ază automat
135
+
136
+2. **Download**: CloudKit changes → Core Data → UI
137
+   - **MUST**: NSPersistentCloudKitContainer sincronizează automat
138
+   - **SHOULD**: Refresh UI după fetch
139
+
140
+3. **Conflict resolution**: Dacă două modificări simultane
141
+   - **MUST**: Mergi pe "last write wins" dacă timestamps diferă
142
+   - **SHOULD**: Loghează conflictul pentru debugging
143
+
144
+### Discovery throttling
145
+
146
+- **MUST**: Max 1 discovery per device per 120s
147
+- **SHOULD**: Previne CloudKit thrashing din repeat BT advertisements
148
+- **MUST**: Reseta timer dacă device disconnect-ează
149
+
150
+## Device Settings Persistence
151
+
152
+### MAC address mapping
153
+
154
+```swift
155
+// CloudDeviceSettingsStore.swift
156
+func upsertDeviceSettings(
157
+    macAddress: String,
158
+    meterName: String,
159
+    tc66TemperatureUnit: TemperatureUnitPreference?,
160
+    connectionMetadata: ConnectionMetadata?,
161
+    discoveryMetadata: DiscoveryMetadata?
162
+)
163
+```
164
+
165
+- **MUST**: macAddress optional (datorită migration)
166
+- **MUST**: meterName unic pe meter (no duplicates)
167
+- **SHOULD**: connectionMetadata.expiresAt = now() + 24h
168
+
169
+### Legacy KV Store
170
+
171
+`NSUbiquitousKeyValueStore` sincronizează:
172
+- `MeterNames`: {macAddress → meterName}
173
+- `TC66TemperatureUnits`: {macAddress → unit}
174
+
175
+- **MUST**: Menţinere backward compat
176
+- **SHOULD**: Migrate pe Core Data la first sync
177
+- **MAY**: Drop la următoarea major version
178
+
179
+## Error handling
180
+
181
+### Bluetooth errors
182
+
183
+```
184
+Error: BT peripheral not found
185
+→ Retry connect cu backoff
186
+→ Max 3 retries, apoi fallback offline mode
187
+
188
+Error: Characteristic not found
189
+→ Log error
190
+→ Mark meter incompatible (UI warning)
191
+
192
+Error: Read timeout
193
+→ Retry measurement request
194
+→ Increment timeout counter
195
+```
196
+
197
+### CloudKit errors
198
+
199
+```
200
+Error: Network unavailable
201
+→ Queue pending changes
202
+→ Retry la next network change
203
+
204
+Error: Conflict detected
205
+→ Merge data (last write wins)
206
+→ Retry sync
207
+
208
+Error: Quota exceeded
209
+→ Log error
210
+→ Notify user (prune old data?)
211
+```
212
+
213
+## Testare
214
+
215
+### Unit tests
216
+
217
+```swift
218
+test_appDataInitializes_CoreDataAndCloudKit()
219
+test_bluetoothConnectInitiatesProperStateTransition()
220
+test_bluetoothDisconnect_CleansUp()
221
+test_autoReconnectBackoff_ExponentialScaling()
222
+test_sessionStartCreatesValidRecord()
223
+test_sessionEndCalculatesEnergy()
224
+test_cloudSyncUpsert_SavesToCoreData()
225
+test_discoveryThrottling_RespectsTiming()
226
+test_conflictResolution_LastWriteWins()
227
+```
228
+
229
+### Integration tests
230
+
231
+- [ ] Full app startup (BT + CloudKit)
232
+- [ ] Connect meter → start session → record measurements → end session
233
+- [ ] Disconnect meter → reconnect avec backoff
234
+- [ ] Device settings sync CloudKit → other device
235
+- [ ] Offline mode (queue changes, sync later)
236
+
237
+## Dependenţe
238
+
239
+- `AppData`: orchestrează tot
240
+- `BluetoothManager`: gestionează Core Bluetooth
241
+- `CloudDeviceSettingsStore`: Core Data + CloudKit
242
+- `ChargeInsightsStore`: sesiuni persistente
243
+- `ConsumptionMonitorStore`: monitorizare consum
244
+
245
+## Notes
246
+
247
+- Legat: [CloudKit Sync](./CloudKitSync.md)
248
+- Legat: [Bluetooth Discovery](./BluetoothDiscovery.md)
249
+- Referință: AppDelegate.swift, SceneDelegate.swift
+163 -0
Documentation/API Reference/Powerbank.md
@@ -0,0 +1,163 @@
1
+# Powerbank Entity
2
+
3
+Reprezentarea unei baterii externe (sursă de energie portabilă).
4
+
5
+## Responsabilități
6
+
7
+- Modelarea unei surse portabile de putere
8
+- Gestionarea sesiunilor de descărcare
9
+- Urmărirea stării de încărcare
10
+- Detectarea şi merge-ul duplicate-ilor
11
+
12
+## Invarianţi
13
+
14
+- **MUST**: Fiecare Powerbank are un `id` (UUID) unic
15
+- **MUST**: Capacity (mAh) trebuie să fie pozitiv
16
+- **MUST**: Un Powerbank poate avea multiple sesiuni de descărcare (neordonate pe timp)
17
+- **MUST**: Sesiunile sunt asociate unui Meter specific
18
+- **SHOULD**: Powerbank-ul cu 0 sesiuni ar trebui marcat pentru curatare
19
+- **MUST**: Nu pot exista duplicate fizice pe același MAC address fără merge explicare
20
+
21
+## Categoriile
22
+
23
+Un Powerbank este o subcategorie a `ChargedDevice` cu `class = .powerbank`.
24
+
25
+## API Public
26
+
27
+### Proprietăţi
28
+
29
+| Proprietate | Tip | Descriere | Observaţii |
30
+|---|---|---|---|
31
+| `id` | UUID | Identificator unic | Generat la criere |
32
+| `name` | String | Nume (ex: "Anker 10000mAh") | Ales de utilizator |
33
+| `macAddress` | String | Adresa BT | Pentru identificare unică |
34
+| `capacity` | Int | Capacitate în mAh | Trebuie să fie > 0 |
35
+| `dischargeSessions` | [DischargeSession] | Sesiunile de descărcare | Cronologice |
36
+| `totalEnergyDelivered` | Double | Total Wh furnizat | Calculat din sesiuni |
37
+| `createdAt` | Date | Data creării | Immutable |
38
+| `lastDischargedAt` | Date? | Data ultimei sesiuni | Nullable |
39
+
40
+### Metode
41
+
42
+```swift
43
+// Gestionare sesiuni
44
+func startDischargeSession(meter: Meter) -> DischargeSession
45
+// MUST: sessionID = UUID()
46
+// MUST: startTime = now()
47
+// MUST: retur DischargeSession cu status "active"
48
+// SHOULD: capacity nu scade (immutable)
49
+
50
+func recordDischargePoint(_ measurement: Measurement, in session: DischargeSession)
51
+// Înregistrează o măsurătoare de descărcare
52
+// MUST: measurement.timestamp > session.startTime
53
+// MUST: nu merge dacă sesiunea e completată
54
+
55
+func endDischargeSession(_ session: DischargeSession)
56
+// MUST: endTime = now()
57
+// MUST: calculează energie furnizată = ∑(V * A * Δt)
58
+// MUST: marchez sesiunea ca "completed"
59
+
60
+func recalculateTotalEnergy()
61
+// Recalculează totalul de Wh furnizat
62
+// SHOULD: se apelează după merge
63
+
64
+// Duplicate handling
65
+func mergeWith(_ other: Powerbank)
66
+// MUST: combină toate sesiunile din ambele PB-uri
67
+// MUST: elimină duplicate-urile de sesiuni
68
+// MUST: recalculează totalul
69
+// SHOULD: logs operaţia
70
+// MUST: marchez celalalt Powerbank ca "merged"
71
+
72
+// Naming
73
+func rename(to newName: String)
74
+// MUST: actualizează name property
75
+// MUST: persistă în Core Data
76
+```
77
+
78
+## Comportamente critice
79
+
80
+### Sesiuni de descărcare
81
+
82
+- **MUST**: Doar o sesiune activă per Powerbank la orice moment
83
+- **MUST**: Sesiunile completate nu pot fi modificate
84
+- **SHOULD**: Sesiunile care durează > 48h sunt marcate cu warning (posibil inactive)
85
+- **MAY**: Sesiuni orfane (meter deconectat > 2h) pot fi finalizate automat
86
+
87
+### Calculul energiei
88
+
89
+- **MUST**: Energie furnizată = ∑(V_out * A_out * Δt) pentru intervale
90
+- **MUST**: Unitatea = Wh (Watt-hours)
91
+- **MUST**: Trebuie să fie ne-negativă (energia nu poate fi absorbi-u înapoi)
92
+- **SHOULD**: Loghează warning dacă energia e negativă (date inconsistente)
93
+
94
+### Duplicate detection și merge
95
+
96
+Se declanşează când:
97
+1. Două Powerbanks cu același MAC address
98
+2. Două Powerbanks cu sesiuni suprapuse pe aceeași perioadă
99
+
100
+Rezoluţie:
101
+- **MUST**: Sesiunea cu mai multe măsurători = "winner"
102
+- **MUST**: Energiile sunt adunate (∑)
103
+- **MUST**: Sesiunile sunt deduplicate după sessionID
104
+- **SHOULD**: Old Powerbank e marcat ca "merged"
105
+- **MAY**: Notifică utilizator
106
+
107
+### State after merge
108
+
109
+- **MUST**: După merge, alte referințe la old Powerbank trebuie actualizate
110
+- **MUST**: Sessionuri din old PB redirecţionează la new PB
111
+- **SHOULD**: Old PB e marcat ReadOnly sau Hidden
112
+
113
+## Testare
114
+
115
+### Unit tests
116
+
117
+```swift
118
+// Sesiuni
119
+test_startSessionCreatesValidRecord()
120
+test_endSessionCalculatesEnergy()
121
+test_activeSessions_OnlyOne()
122
+
123
+// Măsurători
124
+test_recordPointFailsIfSessionEnded()
125
+test_measurementOrdering()
126
+
127
+// Merge
128
+test_mergeDetectsDuplicates()
129
+test_mergeKeepsMoreCompleteData()
130
+test_mergeRecalculatesTotalEnergy()
131
+test_mergeMarksOldAsMerged()
132
+
133
+// Naming
134
+test_renameUpdatesPersistence()
135
+
136
+// Validare
137
+test_capacityMustBePositive()
138
+test_energyCannotBeNegative()
139
+```
140
+
141
+### Integration tests
142
+
143
+- [ ] Powerbank apare după start sesiune
144
+- [ ] Sesiuni complete salvate în Core Data
145
+- [ ] CloudKit sync merge-ul corect
146
+- [ ] Energiile sunt consistente post-merge
147
+- [ ] Name changes persistent pe CloudKit
148
+- [ ] Capacity e immutable (nu se schimbă)
149
+
150
+## Dependenţe
151
+
152
+- `Meter`: pentru măsurători
153
+- `ChargeInsightsStore`: persistență sesiuni
154
+- `CloudKitSync`: replicare iCloud
155
+- `ChargedDevice`: Powerbank e subcategorie
156
+
157
+## Notes
158
+
159
+Documentaţie asoc:
160
+- [Powerbank Category](../Powerbank%20Category.md)
161
+- [Charge Session Integrity](../Charge%20Session%20Integrity%20and%20Conflict%20Healing.md)
162
+- **Status**: Adăugat recent (commit: 0772cad)
163
+- **Merge class+template**: plan în progress, schema v20
+105 -0
Documentation/API Reference/README.md
@@ -0,0 +1,105 @@
1
+# API Reference
2
+
3
+Această documentație defineşte comportamentul aşteptat al entităţilor principale şi operaţiilor aplicaţiei. Scopul este să prevenim regresia şi divergenţele în timp ce aplicaţia evoluează cu ajutorul agenţilor.
4
+
5
+## Entităţi principale
6
+
7
+1. **[Meter](./Meter.md)** - Contor Bluetooth (UM25C, UM34C, TC66C)
8
+   - Reprezentarea unui dispozitiv BT conectat
9
+   - Starea conectorului, metadatele descoperiri
10
+   - Operaţii: connect, disconnect, fetch measurements
11
+
12
+2. **[ChargedDevice](./ChargedDevice.md)** - Dispozitiv încărcat prin meter
13
+   - Modelul unui dispozitiv care se încarcă
14
+   - Sesiuni de încărcare, integritate date
15
+   - Operaţii: start session, record measurement, end session
16
+
17
+3. **[Powerbank](./Powerbank.md)** - Sursă de energie portabilă
18
+   - Modelul unei baterii externe
19
+   - Starea încărcării, sesiiuni de descărcare
20
+   - Operaţii: track discharge, merge duplicates
21
+
22
+## Operaţii core
23
+
24
+1. **[Charging Monitoring](./ChargingMonitoring.md)** - Monitorizarea unei sesiuni de încărcare
25
+   - Start → record measurements → end
26
+   - Colectare date la ~1Hz
27
+   - Validare măsurători, calculul energiei
28
+   - Timeout şi reconnect logic
29
+
30
+2. **[Capacity Measurement](./CapacityMeasurement.md)** - Măsurarea capacităţii bateriei
31
+   - Learning din complete discharge cycles
32
+   - Formula: Capacity = Energy / Nominal_Voltage
33
+   - Weighted moving average din sesiuni multiple
34
+   - Battery health tracking
35
+
36
+3. **[Charge Curve Isolation](./ChargeCurveIsolation.md)** - Izolarea curbei de încărcare
37
+   - Detectare real start (power threshold)
38
+   - Detectare real end (trickle charge)
39
+   - Eliminare zgomot pre/post-charge
40
+   - Combined method (power + battery fallback)
41
+
42
+4. **[Charge Curve Storage](./ChargeCurveStorage.md)** - Stocarea curbelor
43
+   - Persistență în Core Data
44
+   - Sincronizare CloudKit
45
+   - Compresie: downsampling, agregare
46
+   - Archive sesiuni vechi
47
+
48
+5. **[Consumption Measurement](./ConsumptionMeasurement.md)** - Măsurare consum arbitrar
49
+   - Interval liber, nu doar sesiuni complete
50
+   - Selectare din grafic + tail trimming
51
+   - Statistici: energy, power, variance
52
+   - Predicţii cu confidence bounds
53
+   - Salvează stats, NU măsurători brute
54
+
55
+6. **[Idle Consumption Measurement](./IdleConsumptionMeasurement.md)** - Măsurare consum inactiv
56
+   - Device la 100% + după top-up
57
+   - Auto-detect idle phase (power stabilizat)
58
+   - Selection pe grafic pentru refine
59
+   - Profil idle per (device, charger type)
60
+   - Folosit pentru auto-terminate şi bilanț energetic
61
+   - Corectare energie: total - idle_energy
62
+
63
+7. **[CloudKit Sync](./CloudKitSync.md)** - Sincronizare date în CloudKit
64
+   - Mecanismul de replicare către iCloud
65
+   - Conflictele şi rezoluţii
66
+   - Rebuild logic şi versioning
67
+
68
+8. **[Bluetooth Discovery](./BluetoothDiscovery.md)** - Descoperire dispozitive BT
69
+   - Scanning, throttling, caching
70
+   - Advertisement handling
71
+   - Device identification
72
+
73
+9. **[Core Operations](./Operations.md)** - Operaţii principale
74
+   - App lifecycle (AppData, SceneDelegate)
75
+   - Connection management
76
+   - State persistence
77
+
78
+## Convenţii
79
+
80
+- **MUST**: Comportament obligatoriu, nu se poate schimba fără update complet
81
+- **SHOULD**: Comportament aşteptat, regresia = bug
82
+- **MAY**: Comportament opţional, nu afectează integralitate
83
+
84
+## Model versioning
85
+
86
+- **Model versioning**: `cloudStoreRebuildVersion` în AppData
87
+- **Upgrade path**: UnitTests validează migrări la versionare nouă
88
+- **Backward compatibility**: Legacy sync via `NSUbiquitousKeyValueStore`
89
+
90
+## UI Alignment
91
+
92
+- **[UI_ALIGNMENT.md](./UI_ALIGNMENT.md)** — Mapare UI ↔ Documentație
93
+  - Status implementare per operație
94
+  - Needed UI changes untuk align cu docs
95
+  - Checklist alignment per priority
96
+
97
+---
98
+
99
+Fiecare fişier conţine:
100
+
101
+- Responsabilităţi (ce face această entitate)
102
+- Invarianţi (condiţii care trebuie să fie adevărate)
103
+- API public (metodele şi proprietăţile expuse)
104
+- Comportamente critice (cazuri speciale, edge cases)
105
+- Testare (cum validăm corectitudinea)
+473 -0
Documentation/API Reference/UI_ALIGNMENT.md
@@ -0,0 +1,473 @@
1
+# UI Alignment to API Documentation
2
+
3
+Mapare UI ↔ Operații documentate. Status implementare şi TODO-uri pentru a alinia UI la documentație.
4
+
5
+---
6
+
7
+## 1. Charging Monitoring
8
+
9
+**Documentație:** [ChargingMonitoring.md](./ChargingMonitoring.md)
10
+
11
+### Responsabilități
12
+- ✅ Start → record → end sesiune
13
+- ✅ Colectare măsurători ~1Hz
14
+- ✅ Calculul energiei totale
15
+- ⚠️ Timeout logic (incomplete)
16
+
17
+### UI Components
18
+
19
+| File | Status | Alignment | TODO |
20
+|---|---|---|---|
21
+| `MeterChargeRecordTabView.swift` | ✅ Exists | Good | Document timeout handling |
22
+| `ChargeRecordSheetView.swift` | ✅ Exists | Fair | Add measurement count UI |
23
+| `ChargeSessionCompletionSheetView.swift` | ✅ Exists | Fair | Show isolation interval |
24
+| `BatteryCheckpointEditorSheetView.swift` | ✅ Exists | Good | Add stdDev visualization |
25
+
26
+### Current UI Flow
27
+
28
+```
29
+MeterChargeRecordTabView
30
+├─ [Start recording button]
31
+├─ [Live power graph]
32
+├─ [Energy counter (Wh)]
33
+├─ [Duration counter]
34
+└─ [Stop recording button]
35
+   └─ ChargeRecordSheetView
36
+      ├─ [Completion details]
37
+      ├─ [Battery checkpoints]
38
+      ├─ [Charger selection]
39
+      └─ [Save/discard]
40
+```
41
+
42
+### Needed Alignments
43
+
44
+1. **Invariant validation display**
45
+   - MUST show error if start without device
46
+   - SHOULD show warning if session > 24h
47
+   - SHOULD show warning if measurement gap > 5min
48
+
49
+2. **Measurement monitoring**
50
+   - SHOULD display sample count (ex: "324 samples")
51
+   - SHOULD show last update time
52
+   - MAY show power trend (trending up/down/stable)
53
+
54
+3. **Isolation visualization**
55
+   - After stop, show isolated interval on curve
56
+   - Highlight [real_start, real_end]
57
+   - Show pre/post noise regions
58
+
59
+---
60
+
61
+## 2. Capacity Measurement
62
+
63
+**Documentație:** [CapacityMeasurement.md](./CapacityMeasurement.md)
64
+
65
+### Responsabilități
66
+- ✅ Learning din discharge sessions
67
+- ⚠️ Health tracking (not visible in UI)
68
+- ⚠️ Weighted average (calculated, not displayed)
69
+
70
+### UI Components
71
+
72
+| File | Status | Alignment | TODO |
73
+|---|---|---|---|
74
+| `ChargeSessionDetailView.swift` | ✅ Exists | Poor | Add capacity learning |
75
+| `ChargedDeviceSettingsView.swift` | ✅ Exists | Poor | Show measured vs rated capacity |
76
+| — | ❌ Missing | — | Battery health indicator |
77
+| — | ❌ Missing | — | Capacity trend graph |
78
+
79
+### Current UI
80
+
81
+```
82
+ChargeSessionDetailView
83
+├─ [Session info: date, duration, energy]
84
+├─ [Device/charger info]
85
+└─ [Checkpoint list]
86
+```
87
+
88
+### Needed Alignments
89
+
90
+1. **Capacity learning indicator**
91
+   - IF session = complete discharge (0% → 100%)
92
+     - Show: "Capacity learning: 2950 mAh ✓"
93
+   - ELSE
94
+     - Show: "Partial discharge (15%), no capacity learning"
95
+
96
+2. **Battery health display**
97
+   - NEW section: "Battery Health"
98
+     ```
99
+     Measured capacity:    2950 mAh
100
+     Rated capacity:       3000 mAh
101
+     Health:               98% ✓
102
+     ```
103
+   - IF health < 80%: Show warning "Aged battery"
104
+
105
+3. **Multiple measurement history**
106
+   - NEW tab: "Capacity History"
107
+   - Show last 5 discharge cycles
108
+   - Chart: capacity over time
109
+   - Show trend: "Stable" / "Degrading" / "Improving"
110
+
111
+---
112
+
113
+## 3. Charge Curve Isolation
114
+
115
+**Documentație:** [ChargeCurveIsolation.md](./ChargeCurveIsolation.md)
116
+
117
+### Responsabilități
118
+- ✅ Detect start (power threshold)
119
+- ✅ Detect end (trickle charge)
120
+- ⚠️ Visual feedback (weak)
121
+
122
+### UI Components
123
+
124
+| File | Status | Alignment | TODO |
125
+|---|---|---|---|
126
+| `ChargeRecordSheetView.swift` | ✅ Exists | Fair | Add isolation visualization |
127
+| `TimeSeriesChart.swift` | ✅ Exists | Good | Highlight isolated region |
128
+
129
+### Current UI
130
+
131
+```
132
+ChargeRecordSheetView
133
+├─ [Total energy]
134
+├─ [Duration]
135
+└─ [Checkpoints]
136
+```
137
+
138
+### Needed Alignments
139
+
140
+1. **Isolated interval visualization**
141
+   - After completion, show original curve with:
142
+     ```
143
+     Power (W)
144
+     20├──────────────────────
145
+       │ NOISE (discarded)
146
+     10├──────┐
147
+       │ ISOLATED REGION ████████
148
+      5├──────┘
149
+       │ NOISE (discarded)
150
+     0└──────────────────────
151
+     ```
152
+
153
+2. **Detection feedback**
154
+   - Show: "Auto-isolated [10:15–10:45] (30 min)"
155
+   - If manual override: "Custom selection [10:16–10:43]"
156
+
157
+3. **Noise estimation**
158
+   - Show: "Pre-charge noise: 15% of total"
159
+   - Show: "Post-charge noise: 5% of total"
160
+
161
+---
162
+
163
+## 4. Charge Curve Storage
164
+
165
+**Documentație:** [ChargeCurveStorage.md](./ChargeCurveStorage.md)
166
+
167
+### Responsabilități
168
+- ✅ Persist to Core Data
169
+- ✅ CloudKit sync
170
+- ⚠️ Compression visible to user
171
+
172
+### UI Components
173
+
174
+| File | Status | Alignment | TODO |
175
+|---|---|---|---|
176
+| `ChargedDeviceSessionsView.swift` | ✅ Exists | Good | Show compression status |
177
+| `ChargeSessionDetailView.swift` | ✅ Exists | Good | Show sample count |
178
+
179
+### Current UI
180
+
181
+```
182
+ChargedDeviceSessionsView
183
+├─ [Session list (sorted by date)]
184
+└─ [Each row: date, duration, energy]
185
+```
186
+
187
+### Needed Alignments
188
+
189
+1. **Compression indicator**
190
+   - IF session < 7 days: "Full resolution (3600 samples)"
191
+   - ELSE IF session < 365 days: "Downsampled (360 samples)"
192
+   - ELSE: "Archived (metadata only)"
193
+
194
+2. **Sync status**
195
+   - NEW badge: ☁️ (synced) / ⏳ (pending) / ⚠️ (conflict)
196
+
197
+3. **Storage estimate**
198
+   - Footer: "Storage: 2.4 MB (on device) + 1.2 MB (iCloud)"
199
+
200
+---
201
+
202
+## 5. Consumption Measurement
203
+
204
+**Documentație:** [ConsumptionMeasurement.md](./ConsumptionMeasurement.md)
205
+
206
+### Responsabilități
207
+- ✅ Select arbitrary interval
208
+- ✅ Tail trimming (graph selection)
209
+- ✅ Display statistics
210
+- ⚠️ Predictions (weak implementation)
211
+- ✅ Discard measurements
212
+
213
+### UI Components
214
+
215
+| File | Status | Alignment | TODO |
216
+|---|---|---|---|
217
+| `ConsumptionMonitorView.swift` | ✅ Exists | Good | Strengthen predictions |
218
+| `TimeSeriesChart.swift` | ✅ Exists | Good | Add interval selection |
219
+
220
+### Current UI
221
+
222
+```
223
+ConsumptionMonitorView
224
+├─ [Meter selection]
225
+├─ [Active session card]
226
+├─ [Live metrics (Power, Energy, Time)]
227
+├─ [Aggregation duration selector (300s default)]
228
+├─ [Inventory list]
229
+└─ [Saved sessions]
230
+```
231
+
232
+### Needed Alignments
233
+
234
+1. **Tail trimming improvements**
235
+   - Current: "Stop" button
236
+   - NEW: Interactive graph with draggable endpoints
237
+   ```
238
+   Power (W)
239
+   20├──────────────────
240
+     │ ◄─ drag start
241
+     │  [selected region]
242
+   10├──────────────────► drag end
243
+     │
244
+   0 └──────────────────
245
+     10:00   10:30   11:00
246
+   ```
247
+
248
+2. **Prediction display**
249
+   - Current: Only shows "Aggregation duration" selector
250
+   - NEW: Add prediction section
251
+   ```
252
+   Measured (10:00–10:30): 12.5W avg
253
+   Predict next 30 min:     12.5Wh ±2.5Wh (80% confidence)
254
+   Predict next 24h:        300Wh ±120Wh (40% confidence ⚠️)
255
+   ```
256
+
257
+3. **Statistics card**
258
+   - Current: Only total energy + duration
259
+   - NEW: Add table
260
+   ```
261
+   Total Energy:     12.5 Wh
262
+   Avg Power:        25 W
263
+   Peak Power:       32 W
264
+   Min Power:        15 W
265
+   Variance (σ):     ±3.2 W
266
+   Measurement #:    1800 samples
267
+   ```
268
+
269
+4. **Measurement discard confirmation**
270
+   - Current: ✓ (button exists)
271
+   - NEW: Show "Discarding measurements, keeping stats..."
272
+
273
+---
274
+
275
+## 6. Idle Consumption Measurement
276
+
277
+**Documentație:** [IdleConsumptionMeasurement.md](./IdleConsumptionMeasurement.md)
278
+
279
+### Responsabilități
280
+- ❌ NOT implemented in UI
281
+- ❌ NO idle detection
282
+- ❌ NO idle profiles
283
+
284
+### Current State
285
+
286
+```
287
+Status: ❌ MISSING
288
+```
289
+
290
+### Required Implementation
291
+
292
+#### New Views
293
+
294
+1. **IdleConsumptionSetupView**
295
+   ```
296
+   ┌─────────────────────────────┐
297
+   │ Measure Idle Consumption    │
298
+   ├─────────────────────────────┤
299
+   │ Device:      [iPhone 15  ▼] │
300
+   │ Charger:     [USB-C 20W ▼] │
301
+   │ Battery:     [100% ✓]      │
302
+   ├─────────────────────────────┤
303
+   │  [Start measurement]        │
304
+   └─────────────────────────────┘
305
+   ```
306
+
307
+2. **IdleConsumptionMonitorView**
308
+   ```
309
+   ┌──────────────────────────────┐
310
+   │ Recording idle consumption...│
311
+   ├──────────────────────────────┤
312
+   │ Power (W)              5 W   │
313
+   │  Trend: ↘ (stabilizing)     │
314
+   │                              │
315
+   │ Time elapsed:      00:15:32  │
316
+   │                              │
317
+   │ Status:  ✓ Idle detected     │
318
+   │         (power < 5W, stable) │
319
+   │                              │
320
+   │  [Confirm]   [Discard]       │
321
+   └──────────────────────────────┘
322
+   ```
323
+
324
+3. **IdleConsumptionReviewView**
325
+   ```
326
+   ┌──────────────────────────────┐
327
+   │ Idle Profile (Adjust)        │
328
+   ├──────────────────────────────┤
329
+   │ Power (W)                    │
330
+   │ 5 ├─────────────────         │
331
+   │   │     ◄─ slide to adjust   │
332
+   │ 0 │ ████████████► idle       │
333
+   │   └────────────────────      │
334
+   │   10:15      10:45           │
335
+   │                              │
336
+   │ Idle Power:    0.25 W        │
337
+   │ Duration:      30 min        │
338
+   │ Confidence:    85% ✓         │
339
+   │                              │
340
+   │ [Save profile]   [Retry]     │
341
+   └──────────────────────────────┘
342
+   ```
343
+
344
+4. **DeviceIdleProfilesView**
345
+   ```
346
+   ┌──────────────────────────────┐
347
+   │ Idle Profiles                │
348
+   ├──────────────────────────────┤
349
+   │ iPhone 15:                   │
350
+   │  └─ USB-C 20W:     0.25 W   │
351
+   │  └─ MagSafe:       0.20 W   │
352
+   │                              │
353
+   │ iPad Pro:                    │
354
+   │  └─ USB-C 30W:     0.45 W   │
355
+   │                              │
356
+   │  [Measure new]   [Edit]      │
357
+   └──────────────────────────────┘
358
+   ```
359
+
360
+#### Integration Points
361
+
362
+1. **ChargeSessionCompletionSheetView**
363
+   - Add row: "Auto-terminate when: [idle power + 50%]"
364
+   - When user selects idle profile, pre-fill
365
+
366
+2. **MeterChargeRecordTabView**
367
+   - Show idle indicator when power drops below threshold
368
+   - "Idle detected, session can auto-terminate"
369
+
370
+3. **ChargedDeviceSettingsView**
371
+   - NEW section: "Idle Consumption Profile"
372
+   - Show measured idle power per charger
373
+   - Link to "Measure idle..."
374
+
375
+---
376
+
377
+## 7. System Core Operations
378
+
379
+**Documentație:**
380
+- [Operations.md](./Operations.md)
381
+- [CloudKitSync.md](./CloudKitSync.md)
382
+- [BluetoothDiscovery.md](./BluetoothDiscovery.md)
383
+
384
+### Status: ✅ Mostly implemented
385
+
386
+| Component | UI File | Status |
387
+|---|---|---|
388
+| BT Discovery | `SidebarMeterCardView.swift` | ✅ Good |
389
+| Connection | `MeterHomeTabView.swift` | ✅ Good |
390
+| CloudKit sync | (implicit) | ✅ Good |
391
+
392
+### Needed Alignments
393
+
394
+1. **CloudKit sync status display**
395
+   - Current: No visible indicator
396
+   - NEW: Badge in meter card "☁️ synced @ 10:45"
397
+
398
+2. **Connection retry display**
399
+   - Current: Just shows "Offline"
400
+   - NEW: "Offline (retry in 8s...)" or "Reconnecting..."
401
+
402
+3. **Discovery throttling indicator**
403
+   - MAY: Show "Last scan: 45s ago" in debug view
404
+
405
+---
406
+
407
+## Summary Table
408
+
409
+| Operation | Documentație | UI Status | Alignment | Priority |
410
+|---|---|---|---|---|
411
+| Charging Monitoring | ✅ | ✅ Impl | Fair | Medium |
412
+| Capacity Learning | ✅ | ⚠️ Partial | Poor | High |
413
+| Curve Isolation | ✅ | ✅ Impl | Fair | Medium |
414
+| Curve Storage | ✅ | ✅ Impl | Good | Low |
415
+| Consumption Meas. | ✅ | ✅ Impl | Good | Low |
416
+| **Idle Consumption** | ✅ | ❌ Missing | — | **High** |
417
+| Cloud Sync | ✅ | ✅ Impl | Good | Low |
418
+| BT Discovery | ✅ | ✅ Impl | Good | Low |
419
+
420
+---
421
+
422
+## Alignment Checklist
423
+
424
+### High Priority (implement next)
425
+
426
+- [ ] Idle Consumption Measurement (all 4 new views)
427
+- [ ] Capacity Learning display in ChargeSessionDetailView
428
+- [ ] Battery Health section in ChargedDeviceSettingsView
429
+- [ ] Curve Isolation visualization in ChargeRecordSheetView
430
+
431
+### Medium Priority (refine)
432
+
433
+- [ ] Consumption prediction UI (confidence bounds)
434
+- [ ] Measurement validation warnings
435
+- [ ] Compression status indicators
436
+
437
+### Low Priority (nice-to-have)
438
+
439
+- [ ] Discovery throttling display
440
+- [ ] Retry countdown in connection status
441
+- [ ] Storage estimate footer
442
+
443
+---
444
+
445
+## Implementation Guide
446
+
447
+### For each operation:
448
+
449
+1. **Read documentation** (`ChargingMonitoring.md`, etc.)
450
+2. **Identify required UI** (sections above)
451
+3. **Check invariants** (MUST/SHOULD/MAY)
452
+4. **Implement/update views**
453
+5. **Add validation errors**
454
+6. **Test with unit tests**
455
+7. **Link to documentation in comments**
456
+
457
+Example comment in code:
458
+```swift
459
+// See: Documentation/API Reference/ChargingMonitoring.md
460
+// MUST: recordMeasurement fails if sessionState < active
461
+if !session.isActive {
462
+    throw SessionError.invalidState
463
+}
464
+```
465
+
466
+---
467
+
468
+## Notes
469
+
470
+- This document is a living checklist
471
+- Update as UI aligns with docs
472
+- Link each UI file to relevant docs
473
+- Mark `// DOCUMENTED` in code for traced invariants
+15 -0
Documentation/Charger View Organization Policy.md
@@ -0,0 +1,15 @@
1
+# Politica de organizare a view-urilor Charger
2
+
3
+- View-urile care reprezintă flows sau ecrane specifice încărcătoarelor trebuie plasate în folderul `Views/Chargers/`.
4
+- Acest folder este destinat doar charger-only views, nu pentru view-uri generale ale device-urilor sau pentru bara laterală.
5
+- Dacă un view se adresează doar funcționalității de charger (editare charger, wizard standby power, configurații charger), atunci el trebuie să fie în `Views/Chargers/`.
6
+- Nu se folosesc foldere `Chargers` sub `Views/Meter/`, `Views/ChargedDevices/` sau `Views/Sidebar/`.
7
+
8
+## Exemplu
9
+
10
+- `ChargerEditorSheetView.swift` → `USB Meter/Views/Chargers/ChargerEditorSheetView.swift`
11
+- `ChargerStandbyPowerWizardView.swift` → `USB Meter/Views/Chargers/ChargerStandbyPowerWizardView.swift`
12
+
13
+## Motiv
14
+
15
+Separarea charger-only views într-un folder dedicat reduce ambiguitatea și face structura `Views/` mai predictibilă pentru toate flow-urile aplicației.
+56 -0
Documentation/Navigation Style Decisions.md
@@ -0,0 +1,56 @@
1
+# Navigation Style Decisions
2
+
3
+## Obiectiv
4
+
5
+Navigația aplicației folosește stilul SwiftUI implicit, cu un singur set de modificatori aplicat consistent în toate view-urile. Nu există logică condiționată per platformă pentru controlul navigației.
6
+
7
+## Deviații de la comportamentul SwiftUI implicit
8
+
9
+### 1. Titlu inline (`.navigationBarTitleDisplayMode(.inline)`)
10
+
11
+**Aplicat pe:** toate view-urile care au `.navigationTitle(...)`.
12
+
13
+**Motivație:** Implicit, SwiftUI afișează titlul mare (`.large`) în prima fereastră a unui NavigationStack sau NavigationView. Stilul large nu se potrivește layoutului aplicației, care folosește un tab bar custom imediat sub navigation bar — titlul mare consumă spațiu vertical fără beneficiu. Stilul inline plasează titlul centrat în toolbar, aliniind aspectul pe iPhone, iPad și Mac Catalyst.
14
+
15
+**Fișiere afectate:**
16
+- `Views/Meter/MeterView.swift` — live body și offline body
17
+- `Views/ChargedDevices/Details/ChargedDeviceDetailView.swift`
18
+- `Views/ChargedDevices/Sessions/ChargeSessionDetailView.swift`
19
+- `Views/ChargedDevices/Sessions/ChargedDeviceSessionsView.swift`
20
+- `Views/Meter/Tabs/Live/ChargerStandbyPowerWizardView.swift`
21
+- `Views/MeterMappingDebugView.swift`
22
+- `Views/DeviceHelpView.swift`
23
+- Sheets (deja aveau `.inline`): `ChargedDeviceEditorScaffoldView`, `SidebarChargedDeviceLibraryView`, și altele
24
+
25
+### 2. Font titlu navigation bar (19pt semibold)
26
+
27
+**Aplicat în:** `AppDelegate.configureNavigationBarAppearance()` via `UINavigationBarAppearance`.
28
+
29
+**Motivație:** Fontul implicit al titlului inline este `.headline` (17pt semibold), perceput ca prea mic față de densitatea vizuală a conținutului. 19pt semibold oferă mai multă prezență fără a afecta spațiul disponibil, întrucât titlul rămâne pe un singur rând.
30
+
31
+**Configurare:**
32
+```swift
33
+let titleFont = UIFont.systemFont(ofSize: 19, weight: .semibold)
34
+let appearance = UINavigationBarAppearance()
35
+appearance.configureWithDefaultBackground()
36
+appearance.titleTextAttributes = [.font: titleFont]
37
+UINavigationBar.appearance().standardAppearance = appearance
38
+UINavigationBar.appearance().scrollEdgeAppearance = appearance
39
+UINavigationBar.appearance().compactAppearance = appearance
40
+```
41
+
42
+## Ce NU s-a schimbat față de implicit
43
+
44
+- `NavigationView` cu `.navigationViewStyle(.stack)` pe iPhone și `.navigationViewStyle(.columns)` pe iPad/Mac — arhitectural, nu cosmetic
45
+- Toolbar items (`.toolbar { }`) — plasate standard pe `.navigationBarTrailing` / `.cancellationAction` / `.confirmationAction`
46
+- Fundalul navigation bar — `configureWithDefaultBackground()` păstrează comportamentul implicit al sistemului (translucid/blur)
47
+
48
+## Istoric
49
+
50
+Anterior existau mai multe straturi de modificatori conflictuali adăugați în tentative de a obține un layout compact "Nav Control – Title – Tools" pe Mac Catalyst și iPad:
51
+- `navigationBarHidden(landscape)` — ascundea bara în landscape pe Catalyst
52
+- `IOSOnlyNavBar` (ViewModifier) — aplica titlu și toolbar condițional pe `!isTrueMacApp`
53
+- `macNavigationHeader` și `offlineMacHeader` — headere custom inline în VStack care dublau controalele când bara de sistem era vizibilă
54
+- `ToolbarItemGroup(placement: .primaryAction) {}` gol pe Catalyst — crea artefacte vizuale
55
+
56
+Toate au fost eliminate în aprilie 2026. Soluția corectă a fost `.navigationBarTitleDisplayMode(.inline)` aplicat consistent.
+53 -0
Documentation/No Ampere-Hours in UI or Model.md
@@ -0,0 +1,53 @@
1
+# No Ampere-Hours (Ah) in UI or Model
2
+
3
+## Decision
4
+
5
+The application does not display, expose, or compute user-visible measurements in ampere-hours (Ah or mAh). This is a deliberate policy, not an omission.
6
+
7
+## Rationale
8
+
9
+### The physics
10
+
11
+Ampere-hours measure **electric charge** — the integral of current over time (∫ I·dt). They express how many coulombs of charge flowed through a circuit, independent of voltage.
12
+
13
+Watt-hours measure **energy** — the integral of power over time (∫ V·I·dt). Energy is what actually matters for a battery: it tells you how much work the battery can do, accounting for the voltage at which charge is delivered.
14
+
15
+For a battery with a flat discharge curve these two metrics are proportional, but real batteries have voltage that drops as they discharge. A 3.5 V lithium cell and a 5.0 V USB output delivering the "same" mAh are not delivering the same energy. The difference can be 30% or more.
16
+
17
+### The marketing abuse
18
+
19
+Consumer electronics marketing adopted mAh as a proxy for battery capacity because the number is conveniently large:
20
+- "10,000 mAh power bank" sounds more impressive than "37 Wh power bank"
21
+- But 10,000 mAh at 3.7 V (lithium cell voltage) ≈ 37 Wh, while 10,000 mAh at 5 V (USB output voltage) ≈ 50 Wh
22
+- Manufacturers measure at cell voltage; the useful output is at USB voltage — the same number means different things depending on context
23
+
24
+This ambiguity has been widely documented and is a known source of consumer confusion. Rating bodies and careful reviewers use Wh.
25
+
26
+### For this app specifically
27
+
28
+USB Meter measures charge delivered over USB, where voltage is never constant. Reporting in Ah would require specifying *which voltage* to use, and any fixed reference (3.7 V, 5 V, nominal) would be arbitrary and misleading. Wh is unambiguous: it is always the integral of V·I·dt as measured at the USB port.
29
+
30
+## What this means in practice
31
+
32
+### UI
33
+- No measurement expressed in Ah or mAh should appear in any view
34
+- Battery capacity is expressed exclusively in Wh (`capacityEstimateWh`)
35
+- Session energy is expressed exclusively in Wh (`measuredEnergyWh`, `effectiveBatteryEnergyWh`)
36
+
37
+### Model layer (Swift)
38
+- `ChargeSessionSummary`, `ChargeCheckpointSummary`, and `ChargeSessionSampleSummary` do not expose Ah fields
39
+- `TypicalChargeCurvePoint` carries only `averageEnergyWh`
40
+- `ChargedDeviceSummary` capacity estimates are always in Wh
41
+
42
+### Internal / hardware layer
43
+- The USB meters (UM25C, UM34C, TC66C) report both a Wh counter (`recordedWH`) and an Ah counter (`recordedAH`) over Bluetooth
44
+- `Meter.recordedAH` and `Meter.chargeRecordAH` exist as raw hardware counters and are used only internally — they are never surfaced in summaries or shown to users
45
+- The CoreData schema retains `measuredChargeAh`, `meterChargeBaselineAh`, and `meterLastChargeAh` as legacy attributes (data already stored in existing records is preserved), but these attributes are no longer read into Swift model summaries
46
+
47
+## Enforcement
48
+
49
+When adding new features involving energy or capacity:
50
+- Always use Wh as the unit
51
+- Do not introduce new Ah-denominated properties in any public summary struct
52
+- Do not display Ah or mAh strings in any view, label, or tooltip
53
+- If a hardware counter comes in Ah (e.g., from a new meter protocol), store it as a private implementation detail and convert to energy only if an average voltage is reliably known
+85 -0
Documentation/Powerbank Category.md
@@ -0,0 +1,85 @@
1
+# Powerbank Category
2
+
3
+## Definition
4
+
5
+A **powerbank** is a device that has a battery *and* delivers power to other devices. It is conceptually both a charged device (when it is being charged) and a charger (when it is supplying another device's session). For that reason, in this app, a powerbank is a **first-class entity** — separate from `ChargedDevice` and from chargers (which are `ChargedDevice` rows with `deviceClass = .charger`).
6
+
7
+A powerbank can sit on **either side of the meter**:
8
+
9
+- **Subject side (input)** — the powerbank is being charged. The session looks like any device-charging session, except its subject is the powerbank.
10
+- **Source side (output)** — the powerbank is supplying energy to a device that is being charged. The session's subject is that device; the powerbank fills the source slot (the same slot a charger fills for wireless sessions).
11
+
12
+A powerbank can also be both at once if there are two meters in the line (pass-through case): one session on the input meter with the powerbank as subject, one session on the output meter with the powerbank as source. They are independent records linked only by their shared powerbank reference.
13
+
14
+## Battery level reporting
15
+
16
+Powerbanks report their battery in heterogeneous ways:
17
+
18
+- **`.percent`** — 0–100% (treated like any device).
19
+- **`.bars`** — discrete steps, e.g. 4 of 4. The `batteryBarsCount` attribute drives the resolution. Stored canonically as percent (`100 / barsCount * barIndex`); the bars value is also stored on the checkpoint for fidelity.
20
+- **`.fullOnly`** — a single LED that lights only when charging completes. The only honest checkpoint is the 100% anchor; the editor renders a "Full LED is on" affordance and stores `batteryPercent = 100`. Capacity learning treats two consecutive full markers as the bounds of a discharge-then-recharge cycle and uses the source-side energy between them as apparent capacity.
21
+- **`.none`** — no battery level visible. Powerbank-side checkpoints are disabled; capacity learning relies only on full-cycle session energy.
22
+
23
+The reason for keeping `.fullOnly` distinct from `.bars` with `count = 1` is data honesty: a 1-bar gauge would invite "0/1 = not full" entries as if they were datapoints, when in reality "not full" is uninformative (the battery could be anywhere from 0% to 99%). The `.fullOnly` editor only emits the precise signal and skips the meaningless one.
24
+
25
+This is configured in `PowerbankEditorSheetView`. The checkpoint editor (`BatteryCheckpointEditorContentView`) adapts: text input for percent, stepper for bars, disabled state with a hint for none.
26
+
27
+## Sessions and the source slot
28
+
29
+A `ChargeSession` carries:
30
+
31
+- one **subject**: either `chargedDeviceID` (existing) or `chargedPowerbankID` (new, when the powerbank itself is being charged)
32
+- at most one **source**: `chargerID` (existing) or `sourcePowerbankID` (new). May be empty.
33
+
34
+The subject and source columns are mutually exclusive within their pair (i.e. a session cannot have both a `chargedDeviceID` and a `chargedPowerbankID`; same for chargers vs powerbanks on the source side). The store enforces this at session-creation time.
35
+
36
+The session-start UI (`MeterChargeRecordTabView`) presents a **unified Source picker**:
37
+
38
+- For wireless transport: shows None + chargers + powerbanks.
39
+- For wired transport: shows None + powerbanks (chargers don't apply to wired).
40
+- Source is optional; "None" is always valid.
41
+
42
+## Multiple devices, one powerbank
43
+
44
+A powerbank can charge several devices simultaneously, each on its own meter. Each meter has its own `ChargeSession` referencing the same `sourcePowerbankID`. The powerbank's view aggregates across them at view time (no cached aggregate curve in storage). Per-session data stays per-session — this is the **curve duplication** rule: each device's curve lives independently, and the powerbank's visualization sums concurrent curves on the fly.
45
+
46
+The "one open session per meter" healing invariant ([Charge Session Integrity and Conflict Healing.md](Charge%20Session%20Integrity%20and%20Conflict%20Healing.md)) is unchanged: the source-side reference does not affect grouping. Two devices on two meters with the same powerbank source = two independent open sessions, no conflict.
47
+
48
+## Checkpoints with two subjects
49
+
50
+`ChargeCheckpoint` was extended with:
51
+
52
+- `powerbankID: String?` — populated when the checkpoint reflects the powerbank's battery state. Mutually exclusive with `chargedDeviceID`.
53
+- `batteryBarsValue: Int16` — the as-reported bars value (0 when not in bars mode).
54
+
55
+Powerbank-side checkpoints **do not** update the session's `startBatteryPercent` / `endBatteryPercent` fields — those track the device subject only. They also don't trigger device-side capacity learning. They are read at view time when computing powerbank-derived metrics.
56
+
57
+The subject toggle appears in the inline checkpoint editor only when the active session's source is a powerbank with `.percent` or `.bars` reporting.
58
+
59
+## Derived metrics
60
+
61
+Computed view-side at every powerbank summary materialization in `ChargeInsightsStore.fetchPowerbankSummaries()`:
62
+
63
+- **Voltage profile (`sourceVoltageMaxCurrents`)** — bucket source-side sessions by `selectedSourceVoltageVolts` rounded to 0.5V. Per bucket, track the maximum `maximumObservedCurrentAmps`. Surfaces what voltage steps the powerbank actually delivers and at what currents.
64
+- **`sourceMaximumPowerWatts`** — max of `maximumObservedPowerWatts` across all source-side sessions.
65
+- **`sourceEfficiencyFactor`** — `Σ Wh delivered (as source) / Σ Wh received (as subject)`. Computed only when both totals exceed 0.5 Wh.
66
+- **`apparentCapacityWh`** — best-effort: pick the most recent pair of powerbank-side checkpoints with ≥ 30 percent delta and sum the source-side energy across overlapping sessions in that window.
67
+
68
+Persistent fields with the same names exist on the `Powerbank` entity for future write-back; the materialization currently prefers the derived value and falls back to the persisted value when derivation isn't possible.
69
+
70
+## Migration
71
+
72
+Schema version: **USB_Meter 19** (additive — new entity + new optional attributes only; lightweight migration).
73
+
74
+Legacy `ChargedDevice` rows with `deviceClass = "powerbank"` are not yet migrated automatically. They continue to render as ordinary charged devices for now. A one-time migration that promotes them to the new `Powerbank` entity is planned but deferred until there are real legacy rows in CloudKit to test against — the current shape works correctly for clean installs and for upgraders who haven't yet created any class-`.powerbank` `ChargedDevice` rows.
75
+
76
+## Files
77
+
78
+- Schema: `USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 19.xcdatamodel/`
79
+- Model layer: `USB Meter/Model/ChargeInsightsModel.swift` (`PowerbankSummary`, `BatteryLevelReporting`, `CheckpointSubject`, `ChargeSessionSource`)
80
+- Store: `USB Meter/Model/ChargeInsightsStore.swift` (`createPowerbank`, `updatePowerbank`, `deletePowerbank`, `fetchPowerbankSummaries`, `derivedPowerbankMetrics`)
81
+- AppData: `USB Meter/Model/AppData.swift` (`powerbankSummaries`, CRUD wrappers, extended `startChargeSession`/`addBatteryCheckpoint`)
82
+- Views/Powerbanks/: `PowerbankEditorSheetView.swift`, `PowerbankDetailView.swift`
83
+- Views/Sidebar/: `PowerbankSidebarCardView.swift`, `SidebarPowerbanksSectionView.swift`
84
+- Source picker: `USB Meter/Views/Meter/Tabs/ChargeRecord/MeterChargeRecordTabView.swift`
85
+- Checkpoint editor: `USB Meter/Views/ChargedDevices/Sheets/ChargeSession/BatteryCheckpointEditorSheetView.swift`
+13 -4
Documentation/Project Structure and Naming.md
@@ -102,7 +102,6 @@ Views/ChargedDevices/
102 102
     ChargedDeviceIdentityViews.swift
103 103
     ChargedDeviceLibraryRowView.swift
104 104
     ChargedDeviceQRCodeView.swift
105
-    ChargedDeviceSidebarCardView.swift
106 105
   Details/
107 106
     ChargedDeviceDetailView.swift
108 107
   Sessions/
@@ -118,11 +117,21 @@ Views/ChargedDevices/
118 117
       ChargerEditorSheetView.swift
119 118
     Library/
120 119
       ChargedDeviceLibrarySheetView.swift
121
-  Sidebar/
122
-    SidebarChargedDeviceLibraryView.swift
123
-    SidebarChargedDevicesSectionView.swift
124 120
 ```
125 121
 
122
+Views/Sidebar/
123
+  ChargedDeviceSidebarCardView.swift
124
+  SidebarChargedDeviceLibraryView.swift
125
+  SidebarChargedDevicesSectionView.swift
126
+
127
+Views/Chargers/
128
+  ChargerEditorSheetView.swift
129
+  ChargerStandbyPowerWizardView.swift
130
+
131
+Note: sidebar-specific views for the app live in `USB Meter/Views/Sidebar/`, not under feature-specific subfolders.
132
+
133
+Note: charger-specific views live in `USB Meter/Views/Chargers/` when they represent charger-only screens or flows. There is no `USB Meter/Views/Sidebar/Chargers`; sidebar charger views are still part of the shared `Views/Sidebar/` section because the sidebar is a global navigation area.
134
+
126 135
 ## Refactor Examples
127 136
 
128 137
 - `Connection/` -> `Home/`
+6 -0
Documentation/README.md
@@ -14,10 +14,14 @@ It is intended to keep the repository root focused on the app itself while prese
14 14
   Definition + measurement implications for capacity estimation.
15 15
 - `Charge Session Integrity and Conflict Healing.md`
16 16
   The one-active-session-per-meter invariant, how it can be violated in offline sync scenarios, the healing mechanism, and what is not yet covered.
17
+- `Powerbank Category.md`
18
+  Powerbank as a first-class entity (input + output sides), unified source picker, dual-subject checkpoints with percent/bars/none reporting, and view-time derived metrics (voltage profile, max power, efficiency, apparent capacity).
17 19
 - `Project Structure and Naming.md`
18 20
   Naming and file-organization rules for views, features, components, and subviews.
19 21
 - `External Contributions.md`
20 22
   Log of contributions from external collaborators, with technical evaluation per intervention.
23
+- `API Reference/`
24
+  Agent-facing specifications for entities, operations, invariants, UI alignment, storage, sync, and test expectations.
21 25
 - `Research Resources/`
22 26
   External source material plus the notes derived from it.
23 27
 
@@ -27,6 +31,8 @@ We keep two distinct layers of documentation:
27 31
 
28 32
 - project documentation
29 33
   Notes that describe our app, decisions, assumptions, and roadmap
34
+- API reference
35
+  Contract-style behavior specifications used to keep agents and contributors aligned with expected app behavior
30 36
 - research documentation
31 37
   Vendor manuals, software archives, contact sheets, protocol notes, and model-specific findings
32 38
 
+21 -0
Documentation/Sidebar View Organization Policy.md
@@ -0,0 +1,21 @@
1
+# Politica de organizare a view-urilor Sidebar
2
+
3
+- View-urile care fac parte din sidebar trebuie plasate în folderul `Views/Sidebar/`.
4
+- Organizarea trebuie să urmeze structura de navigație, nu modelul de date.
5
+- Când căut un element al sidebar-ului în folderul `Sidebar`, este de așteptat să-l găsesc acolo.
6
+- Nu se folosesc subfoldere `Sidebar` în interiorul altor feature folders, cum ar fi `Views/ChargedDevices/Sidebar/`.
7
+- Folderul `Components/` este rezervat pentru componente reutilizabile care nu sunt legate direct de structura sidebar-ului.
8
+
9
+## Exemplu
10
+
11
+`ChargedDeviceSidebarCardView.swift`, `SidebarChargedDeviceLibraryView.swift`, și `SidebarChargedDevicesSectionView.swift` sunt view-uri utilizate direct în sidebar și, prin urmare, se mută din:
12
+
13
+- `USB Meter/Views/ChargedDevices/Components/` / `USB Meter/Views/ChargedDevices/Sidebar/`
14
+
15
+în:
16
+
17
+- `USB Meter/Views/Sidebar/`
18
+
19
+## Motiv
20
+
21
+Această decizie accelerează navigarea dezvoltatorilor și reduce căutările inutile. Când un view aparține unei secțiuni de navigație specifice, el trebuie să fie imediat vizibil în ierarhia de foldere care reprezintă acea navigație.
+61 -13
USB Meter.xcodeproj/project.pbxproj
@@ -12,6 +12,7 @@
12 12
 		4308CF882417770D0002E80B /* DataGroupsSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4308CF872417770D0002E80B /* DataGroupsSheetView.swift */; };
13 13
 		430CB4FC245E07EB006525C2 /* ChevronView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430CB4FB245E07EB006525C2 /* ChevronView.swift */; };
14 14
 		430CB4FD245E07EB006525C3 /* AdaptiveTabBarPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430CB4FE245E07EB006525C4 /* AdaptiveTabBarPresentation.swift */; };
15
+		3DB80493A78F47DB8613585C /* SidebarToggleToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E3D2C71D8334EC9838A0ADE /* SidebarToggleToolbar.swift */; };
15 16
 		4311E63A241384960080EA59 /* DeviceHelpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4311E639241384960080EA59 /* DeviceHelpView.swift */; };
16 17
 		432EA6442445A559006FC905 /* ChartContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 432EA6432445A559006FC905 /* ChartContext.swift */; };
17 18
 		4347F01D28D717C1007EE7B1 /* CryptoSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 4347F01C28D717C1007EE7B1 /* CryptoSwift */; };
@@ -53,12 +54,14 @@
53 54
 		AAD5F9B12B1CAC7A00F8E4F9 /* SidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAD5F9B22B1CAC7A00F8E4F9 /* SidebarView.swift */; };
54 55
 		B0A000113C8F000100A10011 /* ChargerStandbyPowerStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0A000013C8F000100A10001 /* ChargerStandbyPowerStore.swift */; };
55 56
 		B0A000123C8F000100A10012 /* ChargerStandbyPowerWizardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0A000023C8F000100A10002 /* ChargerStandbyPowerWizardView.swift */; };
57
+		B0A000133C8F000100A10013 /* ConsumptionMonitorStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0A000033C8F000100A10003 /* ConsumptionMonitorStore.swift */; };
58
+		B0A000143C8F000100A10014 /* ConsumptionMonitorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0A000043C8F000100A10004 /* ConsumptionMonitorView.swift */; };
56 59
 		C10000013C8E4A7A00A10001 /* ChargeInsightsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000113C8E4A7A00A10011 /* ChargeInsightsModel.swift */; };
57 60
 		C10000023C8E4A7A00A10002 /* ChargeInsightsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000123C8E4A7A00A10012 /* ChargeInsightsStore.swift */; };
58 61
 		C10000033C8E4A7A00A10003 /* ChargedDeviceQRCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000133C8E4A7A00A10013 /* ChargedDeviceQRCodeView.swift */; };
59 62
 		C10000043C8E4A7A00A10004 /* ChargedDeviceEditorSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000143C8E4A7A00A10014 /* ChargedDeviceEditorSheetView.swift */; };
60 63
 		C10000053C8E4A7A00A10005 /* ChargedDeviceLibrarySheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000153C8E4A7A00A10015 /* ChargedDeviceLibrarySheetView.swift */; };
61
-		C10000063C8E4A7A00A10006 /* ChargedDeviceDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000163C8E4A7A00A10016 /* ChargedDeviceDetailView.swift */; };
64
+		C10000063C8E4A7A00A10006 /* ChargedDeviceSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000163C8E4A7A00A10016 /* ChargedDeviceSettingsView.swift */; };
62 65
 		C10000073C8E4A7A00A10007 /* SidebarChargedDevicesSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000173C8E4A7A00A10017 /* SidebarChargedDevicesSectionView.swift */; };
63 66
 		C10000083C8E4A7A00A10008 /* BatteryCheckpointEditorSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000183C8E4A7A00A10018 /* BatteryCheckpointEditorSheetView.swift */; };
64 67
 		C10000093C8E4A7A00A10009 /* CKModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C10000213C8E4A7A00A10021 /* CKModel.xcdatamodeld */; };
@@ -73,6 +76,10 @@
73 76
 		CD0002053FA0000000000005 /* SidebarChargedDeviceLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0001053FA0000000000005 /* SidebarChargedDeviceLibraryView.swift */; };
74 77
 		CD0002063FA0000000000006 /* ChargedDeviceDetailTabBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0001063FA0000000000006 /* ChargedDeviceDetailTabBarView.swift */; };
75 78
 		CE00CE013C8E4A7A00CE00CE /* ChargerEditorSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE00CE023C8E4A7A00CE00CE /* ChargerEditorSheetView.swift */; };
79
+		A1B2C3D4E5F6A7B8C9D0E211 /* PowerbankEditorSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E201 /* PowerbankEditorSheetView.swift */; };
80
+		A1B2C3D4E5F6A7B8C9D0E212 /* PowerbankDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E202 /* PowerbankDetailView.swift */; };
81
+		A1B2C3D4E5F6A7B8C9D0E213 /* PowerbankSidebarCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E203 /* PowerbankSidebarCardView.swift */; };
82
+		A1B2C3D4E5F6A7B8C9D0E214 /* SidebarPowerbanksSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E204 /* SidebarPowerbanksSectionView.swift */; };
76 83
 		D28F11013C8E4A7A00A10011 /* MeterHomeTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11023C8E4A7A00A10012 /* MeterHomeTabView.swift */; };
77 84
 		D28F11033C8E4A7A00A10013 /* MeterLiveTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11043C8E4A7A00A10014 /* MeterLiveTabView.swift */; };
78 85
 		D28F11053C8E4A7A00A10015 /* MeterChartTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11063C8E4A7A00A10016 /* MeterChartTabView.swift */; };
@@ -94,6 +101,7 @@
94 101
 		D28F11433C8E4A7A00A10053 /* MeterDataGroupsTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11443C8E4A7A00A10054 /* MeterDataGroupsTabView.swift */; };
95 102
 		E430FB6B7CB3E0D4189F6D7D /* MeterMappingDebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56BA4CE53B6B2C4EBA42C81A /* MeterMappingDebugView.swift */; };
96 103
 		F20000036F5D4C95B6487F18 /* ChargingWindowDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000016F5D4C95B6487F16 /* ChargingWindowDetector.swift */; };
104
+		A1B2C3D4E5F6A7B8C9D0E111 /* DeviceProfilesCatalog.json in Resources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E110 /* DeviceProfilesCatalog.json */; };
97 105
 /* End PBXBuildFile section */
98 106
 
99 107
 /* Begin PBXFileReference section */
@@ -132,6 +140,7 @@
132 140
 		4308CF872417770D0002E80B /* DataGroupsSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataGroupsSheetView.swift; sourceTree = "<group>"; };
133 141
 		430CB4FB245E07EB006525C2 /* ChevronView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChevronView.swift; sourceTree = "<group>"; };
134 142
 		430CB4FE245E07EB006525C4 /* AdaptiveTabBarPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveTabBarPresentation.swift; sourceTree = "<group>"; };
143
+		9E3D2C71D8334EC9838A0ADE /* SidebarToggleToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarToggleToolbar.swift; sourceTree = "<group>"; };
135 144
 		4311E639241384960080EA59 /* DeviceHelpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceHelpView.swift; sourceTree = "<group>"; };
136 145
 		432EA6432445A559006FC905 /* ChartContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartContext.swift; sourceTree = "<group>"; };
137 146
 		4351E7BA24685ACD00E798A3 /* CGPoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGPoint.swift; sourceTree = "<group>"; };
@@ -177,12 +186,14 @@
177 186
 		AAD5F9B22B1CAC7A00F8E4F9 /* SidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarView.swift; sourceTree = "<group>"; };
178 187
 		B0A000013C8F000100A10001 /* ChargerStandbyPowerStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargerStandbyPowerStore.swift; sourceTree = "<group>"; };
179 188
 		B0A000023C8F000100A10002 /* ChargerStandbyPowerWizardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargerStandbyPowerWizardView.swift; sourceTree = "<group>"; };
189
+		B0A000033C8F000100A10003 /* ConsumptionMonitorStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsumptionMonitorStore.swift; sourceTree = "<group>"; };
190
+		B0A000043C8F000100A10004 /* ConsumptionMonitorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsumptionMonitorView.swift; sourceTree = "<group>"; };
180 191
 		C10000113C8E4A7A00A10011 /* ChargeInsightsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargeInsightsModel.swift; sourceTree = "<group>"; };
181 192
 		C10000123C8E4A7A00A10012 /* ChargeInsightsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargeInsightsStore.swift; sourceTree = "<group>"; };
182 193
 		C10000133C8E4A7A00A10013 /* ChargedDeviceQRCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceQRCodeView.swift; sourceTree = "<group>"; };
183 194
 		C10000143C8E4A7A00A10014 /* ChargedDeviceEditorSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceEditorSheetView.swift; sourceTree = "<group>"; };
184 195
 		C10000153C8E4A7A00A10015 /* ChargedDeviceLibrarySheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceLibrarySheetView.swift; sourceTree = "<group>"; };
185
-		C10000163C8E4A7A00A10016 /* ChargedDeviceDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceDetailView.swift; sourceTree = "<group>"; };
196
+		C10000163C8E4A7A00A10016 /* ChargedDeviceSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceSettingsView.swift; sourceTree = "<group>"; };
186 197
 		C10000173C8E4A7A00A10017 /* SidebarChargedDevicesSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarChargedDevicesSectionView.swift; sourceTree = "<group>"; };
187 198
 		C10000183C8E4A7A00A10018 /* BatteryCheckpointEditorSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryCheckpointEditorSheetView.swift; sourceTree = "<group>"; };
188 199
 		C10000193C8E4A7A00A10019 /* USB_Meter 4.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 4.xcdatamodel"; sourceTree = "<group>"; };
@@ -204,6 +215,10 @@
204 215
 		CD0001053FA0000000000005 /* SidebarChargedDeviceLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarChargedDeviceLibraryView.swift; sourceTree = "<group>"; };
205 216
 		CD0001063FA0000000000006 /* ChargedDeviceDetailTabBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceDetailTabBarView.swift; sourceTree = "<group>"; };
206 217
 		CE00CE023C8E4A7A00CE00CE /* ChargerEditorSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargerEditorSheetView.swift; sourceTree = "<group>"; };
218
+		A1B2C3D4E5F6A7B8C9D0E201 /* PowerbankEditorSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerbankEditorSheetView.swift; sourceTree = "<group>"; };
219
+		A1B2C3D4E5F6A7B8C9D0E202 /* PowerbankDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerbankDetailView.swift; sourceTree = "<group>"; };
220
+		A1B2C3D4E5F6A7B8C9D0E203 /* PowerbankSidebarCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerbankSidebarCardView.swift; sourceTree = "<group>"; };
221
+		A1B2C3D4E5F6A7B8C9D0E204 /* SidebarPowerbanksSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarPowerbanksSectionView.swift; sourceTree = "<group>"; };
207 222
 		D28F11023C8E4A7A00A10012 /* MeterHomeTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterHomeTabView.swift; sourceTree = "<group>"; };
208 223
 		D28F11043C8E4A7A00A10014 /* MeterLiveTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterLiveTabView.swift; sourceTree = "<group>"; };
209 224
 		D28F11063C8E4A7A00A10016 /* MeterChartTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterChartTabView.swift; sourceTree = "<group>"; };
@@ -226,6 +241,11 @@
226 241
 		E23F11146F5D4C95B6487C94 /* USB_Meter 14.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 14.xcdatamodel"; sourceTree = "<group>"; };
227 242
 		F10000016F5D4C95B6487F15 /* USB_Meter 15.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 15.xcdatamodel"; sourceTree = "<group>"; };
228 243
 		F10000026F5D4C95B6487F17 /* USB_Meter 16.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 16.xcdatamodel"; sourceTree = "<group>"; };
244
+		F10000036F5D4C95B6487F18 /* USB_Meter 17.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 17.xcdatamodel"; sourceTree = "<group>"; };
245
+		F10000046F5D4C95B6487F19 /* USB_Meter 18.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 18.xcdatamodel"; sourceTree = "<group>"; };
246
+		A1B2C3D4E5F6A7B8C9D0E101 /* USB_Meter 19.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 19.xcdatamodel"; sourceTree = "<group>"; };
247
+		A1B2C3D4E5F6A7B8C9D0E102 /* USB_Meter 20.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 20.xcdatamodel"; sourceTree = "<group>"; };
248
+		A1B2C3D4E5F6A7B8C9D0E110 /* DeviceProfilesCatalog.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = DeviceProfilesCatalog.json; sourceTree = "<group>"; };
229 249
 		F20000016F5D4C95B6487F16 /* ChargingWindowDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargingWindowDetector.swift; sourceTree = "<group>"; };
230 250
 /* End PBXFileReference section */
231 251
 
@@ -396,6 +416,7 @@
396 416
 			children = (
397 417
 				4383B464240EB6B200DAAEBF /* UserDefault.swift */,
398 418
 				C1F000023C90000100A10002 /* ChargedDeviceTemplates.json */,
419
+				A1B2C3D4E5F6A7B8C9D0E110 /* DeviceProfilesCatalog.json */,
399 420
 			);
400 421
 			path = Templates;
401 422
 			sourceTree = "<group>";
@@ -461,6 +482,7 @@
461 482
 				C10000123C8E4A7A00A10012 /* ChargeInsightsStore.swift */,
462 483
 				F20000016F5D4C95B6487F16 /* ChargingWindowDetector.swift */,
463 484
 				B0A000013C8F000100A10001 /* ChargerStandbyPowerStore.swift */,
485
+				B0A000033C8F000100A10003 /* ConsumptionMonitorStore.swift */,
464 486
 				C10000213C8E4A7A00A10021 /* CKModel.xcdatamodeld */,
465 487
 				7396C8BB36F4E7F8E0CD8FF8 /* MeterNameStore.swift */,
466 488
 				43CBF676240C043E00255B8B /* BluetoothManager.swift */,
@@ -484,6 +506,8 @@
484 506
 				C10000203C8E4A7A00A10020 /* ChargedDevices */,
485 507
 				56BA4CE53B6B2C4EBA42C81A /* MeterMappingDebugView.swift */,
486 508
 				437D47CF2415F8CF00B7768E /* Meter */,
509
+				F1F1F1F1F1F1F1F1F1F1F1F1 /* Chargers */,
510
+				A1B2C3D4E5F6A7B8C9D0E2FF /* Powerbanks */,
487 511
 				D28F10023C8E4A7A00A10002 /* Components */,
488 512
 				4311E639241384960080EA59 /* DeviceHelpView.swift */,
489 513
 			);
@@ -511,18 +535,32 @@
511 535
 			children = (
512 536
 				AAD5F9B22B1CAC7A00F8E4F9 /* SidebarView.swift */,
513 537
 				43BE08E12F78F49500250EEC /* SidebarList */,
538
+				CD0001033FA0000000000003 /* ChargedDeviceSidebarCardView.swift */,
539
+				CD0001053FA0000000000005 /* SidebarChargedDeviceLibraryView.swift */,
540
+				C10000173C8E4A7A00A10017 /* SidebarChargedDevicesSectionView.swift */,
541
+				A1B2C3D4E5F6A7B8C9D0E203 /* PowerbankSidebarCardView.swift */,
542
+				A1B2C3D4E5F6A7B8C9D0E204 /* SidebarPowerbanksSectionView.swift */,
514 543
 			);
515 544
 			path = Sidebar;
516 545
 			sourceTree = "<group>";
517 546
 		};
547
+		A1B2C3D4E5F6A7B8C9D0E2FF /* Powerbanks */ = {
548
+			isa = PBXGroup;
549
+			children = (
550
+				A1B2C3D4E5F6A7B8C9D0E201 /* PowerbankEditorSheetView.swift */,
551
+				A1B2C3D4E5F6A7B8C9D0E202 /* PowerbankDetailView.swift */,
552
+			);
553
+			path = Powerbanks;
554
+			sourceTree = "<group>";
555
+		};
518 556
 		C10000203C8E4A7A00A10020 /* ChargedDevices */ = {
519 557
 			isa = PBXGroup;
520 558
 			children = (
559
+				B0A000043C8F000100A10004 /* ConsumptionMonitorView.swift */,
521 560
 				CD0000103FA0000000000010 /* Components */,
522 561
 				CD0000113FA0000000000011 /* Details */,
523 562
 				CD0000123FA0000000000012 /* Sessions */,
524 563
 				CD0000133FA0000000000013 /* Sheets */,
525
-				CD0000173FA0000000000017 /* Sidebar */,
526 564
 			);
527 565
 			path = ChargedDevices;
528 566
 			sourceTree = "<group>";
@@ -535,7 +573,6 @@
535 573
 				CD0001013FA0000000000001 /* ChargedDeviceIdentityViews.swift */,
536 574
 				CD0001023FA0000000000002 /* ChargedDeviceLibraryRowView.swift */,
537 575
 				C10000133C8E4A7A00A10013 /* ChargedDeviceQRCodeView.swift */,
538
-				CD0001033FA0000000000003 /* ChargedDeviceSidebarCardView.swift */,
539 576
 			);
540 577
 			path = Components;
541 578
 			sourceTree = "<group>";
@@ -543,7 +580,7 @@
543 580
 		CD0000113FA0000000000011 /* Details */ = {
544 581
 			isa = PBXGroup;
545 582
 			children = (
546
-				C10000163C8E4A7A00A10016 /* ChargedDeviceDetailView.swift */,
583
+				C10000163C8E4A7A00A10016 /* ChargedDeviceSettingsView.swift */,
547 584
 			);
548 585
 			path = Details;
549 586
 			sourceTree = "<group>";
@@ -571,7 +608,6 @@
571 608
 			isa = PBXGroup;
572 609
 			children = (
573 610
 				C10000143C8E4A7A00A10014 /* ChargedDeviceEditorSheetView.swift */,
574
-				CE00CE023C8E4A7A00CE00CE /* ChargerEditorSheetView.swift */,
575 611
 			);
576 612
 			path = Editors;
577 613
 			sourceTree = "<group>";
@@ -593,13 +629,13 @@
593 629
 			path = ChargeSession;
594 630
 			sourceTree = "<group>";
595 631
 		};
596
-		CD0000173FA0000000000017 /* Sidebar */ = {
632
+		F1F1F1F1F1F1F1F1F1F1F1F1 /* Chargers */ = {
597 633
 			isa = PBXGroup;
598 634
 			children = (
599
-				CD0001053FA0000000000005 /* SidebarChargedDeviceLibraryView.swift */,
600
-				C10000173C8E4A7A00A10017 /* SidebarChargedDevicesSectionView.swift */,
635
+				CE00CE023C8E4A7A00CE00CE /* ChargerEditorSheetView.swift */,
636
+				B0A000023C8F000100A10002 /* ChargerStandbyPowerWizardView.swift */,
601 637
 			);
602
-			path = Sidebar;
638
+			path = Chargers;
603 639
 			sourceTree = "<group>";
604 640
 		};
605 641
 		D28F10013C8E4A7A00A10001 /* Sheets */ = {
@@ -627,6 +663,7 @@
627 663
 				430CB4FE245E07EB006525C4 /* AdaptiveTabBarPresentation.swift */,
628 664
 				4360A34C241CBB3800B464F9 /* RSSIView.swift */,
629 665
 				430CB4FB245E07EB006525C2 /* ChevronView.swift */,
666
+				9E3D2C71D8334EC9838A0ADE /* SidebarToggleToolbar.swift */,
630 667
 			);
631 668
 			path = Generic;
632 669
 			sourceTree = "<group>";
@@ -657,7 +694,6 @@
657 694
 			isa = PBXGroup;
658 695
 			children = (
659 696
 				D28F11043C8E4A7A00A10014 /* MeterLiveTabView.swift */,
660
-				B0A000023C8F000100A10002 /* ChargerStandbyPowerWizardView.swift */,
661 697
 				D28F11253C8E4A7A00A10035 /* Subviews */,
662 698
 			);
663 699
 			path = Live;
@@ -844,6 +880,7 @@
844 880
 				43CBF66C240BF3ED00255B8B /* Preview Assets.xcassets in Resources */,
845 881
 				43CBF669240BF3ED00255B8B /* Assets.xcassets in Resources */,
846 882
 				C1F000013C90000100A10001 /* ChargedDeviceTemplates.json in Resources */,
883
+				A1B2C3D4E5F6A7B8C9D0E111 /* DeviceProfilesCatalog.json in Resources */,
847 884
 			);
848 885
 			runOnlyForDeploymentPostprocessing = 0;
849 886
 		};
@@ -885,13 +922,17 @@
885 922
 				C10000033C8E4A7A00A10003 /* ChargedDeviceQRCodeView.swift in Sources */,
886 923
 				C10000043C8E4A7A00A10004 /* ChargedDeviceEditorSheetView.swift in Sources */,
887 924
 				C10000053C8E4A7A00A10005 /* ChargedDeviceLibrarySheetView.swift in Sources */,
888
-				C10000063C8E4A7A00A10006 /* ChargedDeviceDetailView.swift in Sources */,
925
+				C10000063C8E4A7A00A10006 /* ChargedDeviceSettingsView.swift in Sources */,
889 926
 				C100000A3C8E4A7A00A1000A /* ChargedDeviceSessionsView.swift in Sources */,
890 927
 				C100000B3C8E4A7A00A1000B /* ChargeSessionDetailView.swift in Sources */,
891 928
 				C1CCSS013C8E4A7A00CCSS01 /* ChargeSessionCompletionSheetView.swift in Sources */,
892 929
 				C10000073C8E4A7A00A10007 /* SidebarChargedDevicesSectionView.swift in Sources */,
893 930
 				C10000083C8E4A7A00A10008 /* BatteryCheckpointEditorSheetView.swift in Sources */,
894 931
 				CE00CE013C8E4A7A00CE00CE /* ChargerEditorSheetView.swift in Sources */,
932
+				A1B2C3D4E5F6A7B8C9D0E211 /* PowerbankEditorSheetView.swift in Sources */,
933
+				A1B2C3D4E5F6A7B8C9D0E212 /* PowerbankDetailView.swift in Sources */,
934
+				A1B2C3D4E5F6A7B8C9D0E213 /* PowerbankSidebarCardView.swift in Sources */,
935
+				A1B2C3D4E5F6A7B8C9D0E214 /* SidebarPowerbanksSectionView.swift in Sources */,
895 936
 				CD0002013FA0000000000001 /* ChargedDeviceIdentityViews.swift in Sources */,
896 937
 				CD0002023FA0000000000002 /* ChargedDeviceLibraryRowView.swift in Sources */,
897 938
 				CD0002033FA0000000000003 /* ChargedDeviceSidebarCardView.swift in Sources */,
@@ -901,8 +942,11 @@
901 942
 				C10000093C8E4A7A00A10009 /* CKModel.xcdatamodeld in Sources */,
902 943
 				B0A000113C8F000100A10011 /* ChargerStandbyPowerStore.swift in Sources */,
903 944
 				B0A000123C8F000100A10012 /* ChargerStandbyPowerWizardView.swift in Sources */,
945
+				B0A000133C8F000100A10013 /* ConsumptionMonitorStore.swift in Sources */,
946
+				B0A000143C8F000100A10014 /* ConsumptionMonitorView.swift in Sources */,
904 947
 				4360A34D241CBB3800B464F9 /* RSSIView.swift in Sources */,
905 948
 				430CB4FD245E07EB006525C3 /* AdaptiveTabBarPresentation.swift in Sources */,
949
+				3DB80493A78F47DB8613585C /* SidebarToggleToolbar.swift in Sources */,
906 950
 				437D47D12415F91B00B7768E /* MeterLiveContentView.swift in Sources */,
907 951
 				4383B465240EB6B200DAAEBF /* UserDefault.swift in Sources */,
908 952
 				3407A133FADB8858DC2A1FED /* MeterNameStore.swift in Sources */,
@@ -1190,8 +1234,12 @@
1190 1234
 				E23F11146F5D4C95B6487C94 /* USB_Meter 14.xcdatamodel */,
1191 1235
 				F10000016F5D4C95B6487F15 /* USB_Meter 15.xcdatamodel */,
1192 1236
 				F10000026F5D4C95B6487F17 /* USB_Meter 16.xcdatamodel */,
1237
+				F10000036F5D4C95B6487F18 /* USB_Meter 17.xcdatamodel */,
1238
+				F10000046F5D4C95B6487F19 /* USB_Meter 18.xcdatamodel */,
1239
+				A1B2C3D4E5F6A7B8C9D0E101 /* USB_Meter 19.xcdatamodel */,
1240
+				A1B2C3D4E5F6A7B8C9D0E102 /* USB_Meter 20.xcdatamodel */,
1193 1241
 			);
1194
-			currentVersion = F10000026F5D4C95B6487F17 /* USB_Meter 16.xcdatamodel */;
1242
+			currentVersion = A1B2C3D4E5F6A7B8C9D0E102 /* USB_Meter 20.xcdatamodel */;
1195 1243
 			path = CKModel.xcdatamodeld;
1196 1244
 			sourceTree = "<group>";
1197 1245
 			versionGroupType = wrapper.xcdatamodel;
+93 -49
USB Meter/AppDelegate.swift
@@ -15,6 +15,22 @@ import UserNotifications
15 15
 //let btSerial = BluetoothSerial(delegate: BSD())
16 16
 let appData = AppData()
17 17
 private let restoreLogger = Logger(subsystem: "ro.xdev.USB-Meter", category: "Restore")
18
+private enum DebugLogFlag: String {
19
+    case all = "USB_METER_DEBUG_LOGS"
20
+    case bluetooth = "USB_METER_BLUETOOTH_LOGS"
21
+    case cloud = "USB_METER_CLOUD_LOGS"
22
+    case meter = "USB_METER_METER_LOGS"
23
+    case migration = "USB_METER_MIGRATION_LOGS"
24
+    case notifications = "USB_METER_NOTIFICATION_LOGS"
25
+    case restore = "USB_METER_RESTORE_LOGS"
26
+    case sync = "USB_METER_SYNC_LOGS"
27
+}
28
+
29
+public func debugLogFlagEnabled(_ flag: String) -> Bool {
30
+    ProcessInfo.processInfo.environment[DebugLogFlag.all.rawValue] == "1" ||
31
+    ProcessInfo.processInfo.environment[flag] == "1"
32
+}
33
+
18 34
 enum Constants {
19 35
     static let chartUnderscan: CGFloat = 0.5
20 36
     static let chartOverscan: CGFloat = 1 - chartUnderscan
@@ -35,16 +51,16 @@ public func track(_ message: String = "", file: String = #file, function: String
35 51
 }
36 52
 
37 53
 public func restoreTrace(_ message: String) {
54
+    guard debugLogFlagEnabled(DebugLogFlag.restore.rawValue) else { return }
38 55
     restoreLogger.debug("\(message, privacy: .public)")
39 56
 }
40 57
 
41 58
 private func shouldEmitTrackMessage(_ message: String, file: String, function: String) -> Bool {
42 59
     #if DEBUG
43
-    if ProcessInfo.processInfo.environment["USB_METER_VERBOSE_LOGS"] == "1" {
60
+    if debugLogFlagEnabled(DebugLogFlag.all.rawValue) {
44 61
         return true
45 62
     }
46 63
 
47
-    #if targetEnvironment(macCatalyst)
48 64
     let importantMarkers = [
49 65
         "Error",
50 66
         "error",
@@ -63,69 +79,85 @@ private func shouldEmitTrackMessage(_ message: String, file: String, function: S
63 79
         "not supported",
64 80
         "Unexpected",
65 81
         "Invalid Context",
66
-        "ignored",
67
-        "Guard:",
68
-        "Skip data request",
69
-        "Dropping unsolicited data",
70 82
         "This is not possible!",
71
-        "Inferred",
72
-        "Clearing",
73
-        "Reconnecting"
83
+        "Buffer overflow"
74 84
     ]
75 85
 
76 86
     if importantMarkers.contains(where: { message.contains($0) }) {
77 87
         return true
78 88
     }
79 89
 
80
-    let noisyFunctions: Set<String> = [
81
-        "logRuntimeICloudDiagnostics()",
82
-        "refreshCloudAvailability(reason:)",
83
-        "start()",
84
-        "centralManagerDidUpdateState(_:)",
85
-        "discoveredMeter(peripheral:advertising:rssi:)",
86
-        "connect()",
87
-        "connectionEstablished()",
88
-        "peripheral(_:didDiscoverServices:)",
89
-        "peripheral(_:didDiscoverCharacteristicsFor:error:)",
90
-        "refreshOperationalStateIfReady()",
91
-        "peripheral(_:didUpdateNotificationStateFor:error:)",
92
-        "scheduleDataDumpRequest(after:reason:)"
93
-    ]
94
-
95
-    if noisyFunctions.contains(function) {
96
-        return false
97
-    }
98
-
99
-    let noisyMarkers = [
100
-        "Runtime iCloud diagnostics",
101
-        "iCloud availability",
102
-        "Starting Bluetooth manager",
103
-        "Bluetooth is On... Start scanning...",
104
-        "adding new USB Meter",
105
-        "Connect called for",
106
-        "Connection established for",
107
-        "Optional([<CBService:",
108
-        "Optional([<CBCharacteristic:",
109
-        "Waiting for notifications on",
110
-        "Notification state updated for",
111
-        "Peripheral ready with notify",
112
-        "Schedule data request in",
113
-        "Operational state changed"
114
-    ]
115
-
116
-    if noisyMarkers.contains(where: { message.contains($0) }) {
117
-        return false
90
+    if isTrackMessageEnabledByCategory(message, file: file, function: function) {
91
+        return true
118 92
     }
119
-    #endif
120 93
 
121
-    return true
94
+    return false
122 95
     #else
96
+    _ = message
123 97
     _ = file
124 98
     _ = function
125 99
     return false
126 100
     #endif
127 101
 }
128 102
 
103
+private func isTrackMessageEnabledByCategory(_ message: String, file: String, function: String) -> Bool {
104
+    let categories = debugLogCategories(for: message, file: file, function: function)
105
+    return categories.contains { debugLogFlagEnabled($0.rawValue) }
106
+}
107
+
108
+private func debugLogCategories(for message: String, file: String, function: String) -> [DebugLogFlag] {
109
+    var categories = [DebugLogFlag]()
110
+
111
+    if file.contains("Bluetooth") ||
112
+        function.contains("centralManager") ||
113
+        function.contains("peripheral(") ||
114
+        message.contains("Bluetooth") ||
115
+        message.contains("BLE discovery") ||
116
+        message.contains("peripheral") ||
117
+        message.contains("characteristic") ||
118
+        message.contains("service") {
119
+        categories.append(.bluetooth)
120
+    }
121
+
122
+    if file.contains("Meter.swift") ||
123
+        message.contains("data request") ||
124
+        message.contains("Operational state") ||
125
+        message.contains("recordingThreshold") ||
126
+        message.contains("screenTimeout") ||
127
+        message.contains("screenBrightness") ||
128
+        message.contains("volatile memory") ||
129
+        message.contains("charger type") {
130
+        categories.append(.meter)
131
+    }
132
+
133
+    if message.contains("CloudKit") ||
134
+        message.contains("iCloud") ||
135
+        message.contains("ubiquityIdentityToken") {
136
+        categories.append(.cloud)
137
+    }
138
+
139
+    if file.contains("MeterNameStore") ||
140
+        file.contains("ChargerStandbyPowerStore") ||
141
+        message.contains("KVS") ||
142
+        message.contains("ubiquitous") {
143
+        categories.append(.sync)
144
+    }
145
+
146
+    if file.contains("ChargeInsightsStore") ||
147
+        message.contains("promoted legacy") ||
148
+        message.contains("synthesized custom") ||
149
+        message.contains("healed duplicate") {
150
+        categories.append(.migration)
151
+    }
152
+
153
+    if message.contains("notification") ||
154
+        message.contains("remote notifications") {
155
+        categories.append(.notifications)
156
+    }
157
+
158
+    return categories
159
+}
160
+
129 161
 @UIApplicationMain
130 162
 class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
131 163
     private let cloudKitContainerIdentifier = "iCloud.ro.xdev.USB-Meter"
@@ -136,11 +168,23 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
136 168
         UNUserNotificationCenter.current().delegate = self
137 169
         application.registerForRemoteNotifications()
138 170
         appData.activateChargeInsights(context: persistentContainer.viewContext)
171
+        configureNavigationBarAppearance()
139 172
         return true
140 173
     }
141 174
 
175
+    private func configureNavigationBarAppearance() {
176
+        let titleFont = UIFont.systemFont(ofSize: 19, weight: .semibold)
177
+        let appearance = UINavigationBarAppearance()
178
+        appearance.configureWithDefaultBackground()
179
+        appearance.titleTextAttributes = [.font: titleFont]
180
+        UINavigationBar.appearance().standardAppearance = appearance
181
+        UINavigationBar.appearance().scrollEdgeAppearance = appearance
182
+        UINavigationBar.appearance().compactAppearance = appearance
183
+    }
184
+
142 185
     private func logRuntimeICloudDiagnostics() {
143 186
         #if DEBUG
187
+        guard debugLogFlagEnabled(DebugLogFlag.cloud.rawValue) else { return }
144 188
         let hasUbiquityIdentityToken = FileManager.default.ubiquityIdentityToken != nil
145 189
         track("Runtime iCloud diagnostics: ubiquityIdentityTokenAvailable=\(hasUbiquityIdentityToken)")
146 190
         CKContainer(identifier: cloudKitContainerIdentifier).accountStatus { status, error in
+0 -1
USB Meter/Extensions/Data.swift
@@ -23,7 +23,6 @@ extension Data {
23 23
     
24 24
     func value<T>(from: Int) -> T {
25 25
         let to = from + MemoryLayout<T>.size
26
-        //track("size: \(self.count) from:\(from) to:\(to)")
27 26
         return self.subdata(in: from..<to).withUnsafeBytes { $0.load(as: T.self) }
28 27
     }
29 28
 
+0 -24
USB Meter/Extensions/View.swift
@@ -7,27 +7,3 @@
7 7
 //
8 8
 
9 9
 import SwiftUI
10
-
11
-/* MARK: Iusless...
12
-enum XNavigationViewStyle {
13
-    case auto
14
-    case doubleColumn
15
-    case stack
16
-}
17
-
18
-extension View {
19
-    func xNavigationViewStyle(_ style: XNavigationViewStyle) -> some View {
20
-        switch style {
21
-        case .auto:
22
-            track("auto")
23
-            return AnyView(self.navigationViewStyle(DefaultNavigationViewStyle()))
24
-        case .doubleColumn:
25
-            track("doubleColumn")
26
-            return AnyView(self.navigationViewStyle(DoubleColumnNavigationViewStyle()))
27
-        case .stack:
28
-            track("stack")
29
-            return AnyView(self.navigationViewStyle(StackNavigationViewStyle()))
30
-        }
31
-    }
32
-}
33
-*/
+264 -84
USB Meter/Model/AppData.swift
@@ -42,6 +42,7 @@ final class AppData : ObservableObject {
42 42
     private var chargeInsightsStoreObserver: AnyCancellable?
43 43
     private var chargeInsightsRemoteObserver: AnyCancellable?
44 44
     private var chargerStandbyPowerStoreObserver: AnyCancellable?
45
+    private var consumptionMonitorStoreObserver: AnyCancellable?
45 46
     private var pendingChargedDevicesReloadWorkItem: DispatchWorkItem?
46 47
     private var chargeInsightsReadStore: ChargeInsightsStore?
47 48
     private var pendingChargeObservationSnapshots: [String: ChargingMonitorSnapshot] = [:]
@@ -57,6 +58,7 @@ final class AppData : ObservableObject {
57 58
     private let meterStore = MeterNameStore.shared
58 59
     private var chargeInsightsStore: ChargeInsightsStore?
59 60
     private let chargerStandbyPowerStore = ChargerStandbyPowerStore()
61
+    private let consumptionMonitorStore = ConsumptionMonitorStore()
60 62
     private let chargeNotificationCoordinator = ChargeNotificationCoordinator()
61 63
     private var meterSummariesCache: (version: Int, summaries: [MeterSummary])?
62 64
     private var meterSummariesVersion: Int = 0
@@ -82,6 +84,11 @@ final class AppData : ObservableObject {
82 84
             .sink { [weak self] _ in
83 85
                 self?.reloadChargedDevices()
84 86
             }
87
+        consumptionMonitorStoreObserver = NotificationCenter.default.publisher(for: .consumptionMonitorStoreDidChange)
88
+            .receive(on: DispatchQueue.main)
89
+            .sink { [weak self] _ in
90
+                self?.reloadChargedDevices()
91
+            }
85 92
     }
86 93
 
87 94
     let bluetoothManager = BluetoothManager()
@@ -94,7 +101,9 @@ final class AppData : ObservableObject {
94 101
         }
95 102
     }
96 103
     @Published private(set) var chargedDevices: [ChargedDeviceSummary] = []
104
+    @Published private(set) var powerbanks: [PowerbankSummary] = []
97 105
     @Published private(set) var activeChargerStandbySessions: [String: ChargerStandbyPowerMonitorSession] = [:]
106
+    @Published private(set) var activeConsumptionSessions: [String: ConsumptionMonitorLiveSession] = [:]
98 107
 
99 108
     var deviceSummaries: [ChargedDeviceSummary] {
100 109
         chargedDevices.filter { !$0.isCharger }
@@ -104,6 +113,10 @@ final class AppData : ObservableObject {
104 113
         chargedDevices.filter { $0.isCharger }
105 114
     }
106 115
 
116
+    var powerbankSummaries: [PowerbankSummary] {
117
+        powerbanks
118
+    }
119
+
107 120
     var cloudAvailability: MeterNameStore.CloudAvailability {
108 121
         meterStore.currentCloudAvailability
109 122
     }
@@ -165,9 +178,39 @@ final class AppData : ObservableObject {
165 178
         }
166 179
 
167 180
         chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
181
+        seedDeviceProfilesCatalogIfNeeded()
182
+        migrateDeviceProfilesIfNeeded()
168 183
         reloadChargedDevices()
169 184
     }
170 185
 
186
+    private static let cloudProfileSeedVersionKey = "cloudProfileSeedVersion"
187
+    private static let currentCloudProfileSeedVersion: Int = 1
188
+    private static let cloudDeviceProfileMigrationVersionKey = "cloudDeviceProfileMigrationVersion"
189
+    private static let currentCloudDeviceProfileMigrationVersion: Int = 1
190
+
191
+    private func seedDeviceProfilesCatalogIfNeeded() {
192
+        let defaults = UserDefaults.standard
193
+        let installed = defaults.integer(forKey: AppData.cloudProfileSeedVersionKey)
194
+        guard installed < AppData.currentCloudProfileSeedVersion else { return }
195
+
196
+        let catalog = DeviceProfileCatalog.shared.profiles
197
+        guard catalog.isEmpty == false else { return }
198
+
199
+        if chargeInsightsStore?.seedDeviceProfilesCatalog(catalog) == true {
200
+            defaults.set(AppData.currentCloudProfileSeedVersion, forKey: AppData.cloudProfileSeedVersionKey)
201
+        }
202
+    }
203
+
204
+    private func migrateDeviceProfilesIfNeeded() {
205
+        let defaults = UserDefaults.standard
206
+        let installed = defaults.integer(forKey: AppData.cloudDeviceProfileMigrationVersionKey)
207
+        guard installed < AppData.currentCloudDeviceProfileMigrationVersion else { return }
208
+
209
+        if chargeInsightsStore?.migrateDevicesToProfiles() == true {
210
+            defaults.set(AppData.currentCloudDeviceProfileMigrationVersion, forKey: AppData.cloudDeviceProfileMigrationVersionKey)
211
+        }
212
+    }
213
+
171 214
     func meterName(for macAddress: String) -> String? {
172 215
         meterStore.name(for: macAddress)
173 216
     }
@@ -219,6 +262,11 @@ final class AppData : ObservableObject {
219 262
                 return session
220 263
             }
221 264
         }
265
+        for powerbank in powerbanks {
266
+            if let session = (powerbank.sessionsAsSubject + powerbank.sessionsAsSource).first(where: { $0.id == id }) {
267
+                return session
268
+            }
269
+        }
222 270
         return nil
223 271
     }
224 272
 
@@ -242,37 +290,6 @@ final class AppData : ObservableObject {
242 290
         }
243 291
     }
244 292
 
245
-    func currentChargedDeviceSummary(for meterMACAddress: String) -> ChargedDeviceSummary? {
246
-        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
247
-
248
-        if let activeSession = cachedActiveChargeSessionSummary(for: normalizedMAC),
249
-           let liveDevice = chargedDevices.first(where: {
250
-               $0.id == activeSession.chargedDeviceID && $0.isCharger == false
251
-           }) {
252
-            return liveDevice
253
-        }
254
-
255
-        return chargedDevices.first(where: {
256
-            $0.isCharger == false && $0.lastAssociatedMeterMAC == normalizedMAC
257
-        })
258
-    }
259
-
260
-    func currentChargerSummary(for meterMACAddress: String) -> ChargedDeviceSummary? {
261
-        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
262
-
263
-        if let activeSession = cachedActiveChargeSessionSummary(for: normalizedMAC),
264
-           let chargerID = activeSession.chargerID,
265
-           let liveCharger = chargedDevices.first(where: {
266
-               $0.id == chargerID && $0.isCharger
267
-           }) {
268
-            return liveCharger
269
-        }
270
-
271
-        return chargedDevices.first(where: {
272
-            $0.isCharger && $0.lastAssociatedMeterMAC == normalizedMAC
273
-        })
274
-    }
275
-
276 293
     func activeChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
277 294
         let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
278 295
 
@@ -369,30 +386,114 @@ final class AppData : ObservableObject {
369 386
         return didDelete
370 387
     }
371 388
 
389
+    // MARK: - Consumption Monitor
390
+
391
+    func consumptionMonitorSession(for meterMACAddress: String) -> ConsumptionMonitorLiveSession? {
392
+        activeConsumptionSessions[Self.normalizedMACAddress(meterMACAddress)]
393
+    }
394
+
395
+    @discardableResult
396
+    func startConsumptionMonitor(for deviceID: UUID, on meter: Meter) -> Bool {
397
+        let normalizedMAC = Self.normalizedMACAddress(meter.btSerial.macAddress.description)
398
+        if let existing = activeConsumptionSessions[normalizedMAC] {
399
+            return existing.chargedDeviceID == deviceID
400
+        }
401
+
402
+        let sessionID = UUID()
403
+        let now = Date()
404
+        let session = ConsumptionMonitorLiveSession(
405
+            sessionID: sessionID,
406
+            chargedDeviceID: deviceID,
407
+            meterMACAddress: meter.btSerial.macAddress.description,
408
+            startedAt: now
409
+        )
410
+
411
+        let meterSummary = meterSummaries.first { $0.macAddress == meter.btSerial.macAddress.description }
412
+        session.meterName = meterSummary?.displayName
413
+        session.meterModel = meterSummary?.modelSummary
414
+
415
+        session.onChange = { [weak self] in
416
+            self?.scheduleObjectWillChange()
417
+        }
418
+        session.onSample = { [weak self, weak session] sample in
419
+            guard let self, let session else { return }
420
+            self.consumptionMonitorStore.appendSample(sample, to: session.sessionID)
421
+        }
422
+
423
+        let initialRecord = ConsumptionMonitorSessionSummary(
424
+            id: sessionID,
425
+            chargedDeviceID: deviceID,
426
+            meterMACAddress: meter.btSerial.macAddress.description,
427
+            meterName: session.meterName,
428
+            meterModel: session.meterModel,
429
+            startedAt: now,
430
+            endedAt: nil,
431
+            samples: []
432
+        )
433
+        consumptionMonitorStore.save(initialRecord)
434
+
435
+        activeConsumptionSessions[normalizedMAC] = session
436
+        session.start()
437
+
438
+        if meter.operationalState == .peripheralNotConnected {
439
+            meter.connect()
440
+        }
441
+
442
+        reloadChargedDevices()
443
+        return true
444
+    }
445
+
446
+    @discardableResult
447
+    func stopConsumptionMonitor(for meterMACAddress: String, save: Bool) -> Bool {
448
+        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
449
+        guard let session = activeConsumptionSessions[normalizedMAC] else { return false }
450
+
451
+        session.stop()
452
+        activeConsumptionSessions[normalizedMAC] = nil
453
+
454
+        if save {
455
+            consumptionMonitorStore.completeSession(id: session.sessionID, endedAt: Date())
456
+        } else {
457
+            consumptionMonitorStore.removeSession(id: session.sessionID, deviceID: session.chargedDeviceID)
458
+        }
459
+
460
+        reloadChargedDevices()
461
+        return true
462
+    }
463
+
464
+    @discardableResult
465
+    func deleteConsumptionSession(id: UUID, deviceID: UUID) -> Bool {
466
+        let didDelete = consumptionMonitorStore.removeSession(id: id, deviceID: deviceID)
467
+        if didDelete { reloadChargedDevices() }
468
+        return didDelete
469
+    }
470
+
372 471
     @discardableResult
373 472
     func createDevice(
374 473
         name: String,
375 474
         deviceClass: ChargedDeviceClass,
376 475
         templateID: String?,
476
+        profileID: String? = nil,
477
+        hasInternalSubject: Bool = false,
377 478
         chargingStateAvailability: ChargingStateAvailability,
378 479
         supportsWiredCharging: Bool,
379 480
         supportsWirelessCharging: Bool,
380 481
         wirelessChargingProfile: WirelessChargingProfile,
381 482
         configuredCompletionCurrents: [ChargeSessionKind: Double],
382
-        notes: String?,
383
-        meterMACAddress: String?
483
+        notes: String?
384 484
     ) -> Bool {
385 485
         let didSave = chargeInsightsStore?.createDevice(
386 486
             name: name,
387 487
             deviceClass: deviceClass,
388 488
             templateID: templateID,
489
+            profileID: profileID,
490
+            hasInternalSubject: hasInternalSubject,
389 491
             chargingStateAvailability: chargingStateAvailability,
390 492
             supportsWiredCharging: supportsWiredCharging,
391 493
             supportsWirelessCharging: supportsWirelessCharging,
392 494
             wirelessChargingProfile: wirelessChargingProfile,
393 495
             configuredCompletionCurrents: configuredCompletionCurrents,
394
-            notes: notes,
395
-            assignTo: meterMACAddress
496
+            notes: notes
396 497
         ) ?? false
397 498
 
398 499
         if didSave {
@@ -406,20 +507,73 @@ final class AppData : ObservableObject {
406 507
     func createCharger(
407 508
         name: String,
408 509
         chargerType: ChargerType,
409
-        notes: String?,
410
-        meterMACAddress: String?
510
+        notes: String?
411 511
     ) -> Bool {
412 512
         let didSave = chargeInsightsStore?.createCharger(
413 513
             name: name,
414 514
             chargerType: chargerType,
415
-            notes: notes,
416
-            assignTo: meterMACAddress
515
+            notes: notes
516
+        ) ?? false
517
+
518
+        if didSave {
519
+            reloadChargedDevices()
520
+        }
521
+
522
+        return didSave
523
+    }
524
+
525
+    @discardableResult
526
+    func createPowerbank(
527
+        name: String,
528
+        templateID: String?,
529
+        batteryLevelReporting: BatteryLevelReporting,
530
+        batteryBarsCount: Int,
531
+        notes: String?
532
+    ) -> Bool {
533
+        let didSave = chargeInsightsStore?.createPowerbank(
534
+            name: name,
535
+            templateID: templateID,
536
+            batteryLevelReporting: batteryLevelReporting,
537
+            batteryBarsCount: batteryBarsCount,
538
+            notes: notes
417 539
         ) ?? false
418 540
 
419 541
         if didSave {
420 542
             reloadChargedDevices()
421 543
         }
544
+        return didSave
545
+    }
546
+
547
+    @discardableResult
548
+    func updatePowerbank(
549
+        id: UUID,
550
+        name: String,
551
+        templateID: String?,
552
+        batteryLevelReporting: BatteryLevelReporting,
553
+        batteryBarsCount: Int,
554
+        notes: String?
555
+    ) -> Bool {
556
+        let didSave = chargeInsightsStore?.updatePowerbank(
557
+            id: id,
558
+            name: name,
559
+            templateID: templateID,
560
+            batteryLevelReporting: batteryLevelReporting,
561
+            batteryBarsCount: batteryBarsCount,
562
+            notes: notes
563
+        ) ?? false
422 564
 
565
+        if didSave {
566
+            reloadChargedDevices()
567
+        }
568
+        return didSave
569
+    }
570
+
571
+    @discardableResult
572
+    func deletePowerbank(id: UUID) -> Bool {
573
+        let didSave = chargeInsightsStore?.deletePowerbank(id: id) ?? false
574
+        if didSave {
575
+            reloadChargedDevices()
576
+        }
423 577
         return didSave
424 578
     }
425 579
 
@@ -429,6 +583,8 @@ final class AppData : ObservableObject {
429 583
         name: String,
430 584
         deviceClass: ChargedDeviceClass,
431 585
         templateID: String?,
586
+        profileID: String? = nil,
587
+        hasInternalSubject: Bool = false,
432 588
         chargingStateAvailability: ChargingStateAvailability,
433 589
         supportsWiredCharging: Bool,
434 590
         supportsWirelessCharging: Bool,
@@ -441,6 +597,8 @@ final class AppData : ObservableObject {
441 597
             name: name,
442 598
             deviceClass: deviceClass,
443 599
             templateID: templateID,
600
+            profileID: profileID,
601
+            hasInternalSubject: hasInternalSubject,
444 602
             chargingStateAvailability: chargingStateAvailability,
445 603
             supportsWiredCharging: supportsWiredCharging,
446 604
             supportsWirelessCharging: supportsWirelessCharging,
@@ -477,24 +635,6 @@ final class AppData : ObservableObject {
477 635
         return didSave
478 636
     }
479 637
 
480
-    @discardableResult
481
-    func assignChargedDevice(_ chargedDeviceID: UUID, to meterMACAddress: String) -> Bool {
482
-        let didSave = chargeInsightsStore?.assignChargedDevice(id: chargedDeviceID, to: meterMACAddress) ?? false
483
-        if didSave {
484
-            reloadChargedDevices()
485
-        }
486
-        return didSave
487
-    }
488
-
489
-    @discardableResult
490
-    func assignCharger(_ chargerID: UUID, to meterMACAddress: String) -> Bool {
491
-        let didSave = chargeInsightsStore?.assignCharger(id: chargerID, to: meterMACAddress) ?? false
492
-        if didSave {
493
-            reloadChargedDevices()
494
-        }
495
-        return didSave
496
-    }
497
-
498 638
     func restoreChargeMonitoringStateIfNeeded(for meter: Meter) {
499 639
         guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
500 640
             return
@@ -510,6 +650,7 @@ final class AppData : ObservableObject {
510 650
         for meter: Meter,
511 651
         chargedDeviceID: UUID,
512 652
         chargerID: UUID?,
653
+        sourcePowerbankID: UUID? = nil,
513 654
         chargingTransportMode: ChargingTransportMode,
514 655
         chargingStateMode: ChargingStateMode,
515 656
         autoStopEnabled: Bool,
@@ -526,6 +667,7 @@ final class AppData : ObservableObject {
526 667
             for: snapshot,
527 668
             chargedDeviceID: chargedDeviceID,
528 669
             chargerID: chargerID,
670
+            sourcePowerbankID: sourcePowerbankID,
529 671
             chargingTransportMode: chargingTransportMode,
530 672
             chargingStateMode: chargingStateMode,
531 673
             autoStopEnabled: autoStopEnabled,
@@ -550,6 +692,40 @@ final class AppData : ObservableObject {
550 692
         return didSave
551 693
     }
552 694
 
695
+    @discardableResult
696
+    func startPowerbankChargeSession(
697
+        for meter: Meter,
698
+        powerbankID: UUID,
699
+        sourcePowerbankID: UUID? = nil,
700
+        initialBatteryPercent: Double?,
701
+        startsFromFlatBattery: Bool
702
+    ) -> Bool {
703
+        meter.resetMeterCountersForNewSession()
704
+
705
+        guard let snapshot = meter.chargingMonitorSnapshot else {
706
+            return false
707
+        }
708
+
709
+        let didSave = chargeInsightsStore?.startPowerbankSession(
710
+            for: snapshot,
711
+            powerbankID: powerbankID,
712
+            sourcePowerbankID: sourcePowerbankID,
713
+            autoStopEnabled: false,
714
+            initialBatteryPercent: initialBatteryPercent,
715
+            startsFromFlatBattery: startsFromFlatBattery
716
+        ) ?? false
717
+        if didSave {
718
+            meter.resetChargeRecordGraph()
719
+            if let activeSession = chargeInsightsStore?.activeChargeSessionSummary(
720
+                forMeterMACAddress: meter.btSerial.macAddress.description
721
+            ) {
722
+                meter.restoreChargeMonitoringIfNeeded(from: activeSession)
723
+            }
724
+            reloadChargedDevices()
725
+        }
726
+        return didSave
727
+    }
728
+
553 729
     @discardableResult
554 730
     func pauseChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool {
555 731
         let observedAt = meter?.chargingMonitorSnapshot?.observedAt ?? Date()
@@ -599,6 +775,16 @@ final class AppData : ObservableObject {
599 775
         }
600 776
 
601 777
         stageChargeObservation(snapshot)
778
+
779
+        let normalizedMAC = Self.normalizedMACAddress(meter.btSerial.macAddress.description)
780
+        if let consumptionSession = activeConsumptionSessions[normalizedMAC] {
781
+            consumptionSession.observe(
782
+                powerWatts: snapshot.powerWatts,
783
+                currentAmps: snapshot.currentAmps,
784
+                voltageVolts: snapshot.voltageVolts,
785
+                observedAt: observedAt
786
+            )
787
+        }
602 788
     }
603 789
 
604 790
     @discardableResult
@@ -607,13 +793,11 @@ final class AppData : ObservableObject {
607 793
 
608 794
         let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description)
609 795
         let checkpointEnergyWh = activeSession.map { displayedSessionEnergyWh(for: $0, on: meter) }
610
-        let checkpointChargeAh = activeSession.map { displayedSessionChargeAh(for: $0, on: meter) }
611 796
 
612 797
         let didSave = chargeInsightsStore?.addBatteryCheckpoint(
613 798
             percent: percent,
614 799
             for: meter.btSerial.macAddress.description,
615
-            measuredEnergyWh: checkpointEnergyWh,
616
-            measuredChargeAh: checkpointChargeAh
800
+            measuredEnergyWh: checkpointEnergyWh
617 801
         ) ?? false
618 802
 
619 803
         if didSave {
@@ -646,7 +830,8 @@ final class AppData : ObservableObject {
646 830
         percent: Double,
647 831
         for sessionID: UUID,
648 832
         measuredEnergyWh: Double?,
649
-        measuredChargeAh: Double?
833
+        subject: CheckpointSubject = .chargedDevice,
834
+        barsValue: Int = 0
650 835
     ) -> Bool {
651 836
         guard canAddBatteryCheckpoint(to: sessionID) else {
652 837
             return false
@@ -656,7 +841,8 @@ final class AppData : ObservableObject {
656 841
             percent: percent,
657 842
             for: sessionID,
658 843
             measuredEnergyWh: measuredEnergyWh,
659
-            measuredChargeAh: measuredChargeAh
844
+            subject: subject,
845
+            barsValue: barsValue
660 846
         ) ?? false
661 847
 
662 848
         if didSave {
@@ -735,6 +921,15 @@ final class AppData : ObservableObject {
735 921
         return didSave
736 922
     }
737 923
 
924
+    @discardableResult
925
+    func commitSessionTrim(sessionID: UUID) -> Bool {
926
+        let didSave = chargeInsightsStore?.commitSessionTrim(sessionID: sessionID) ?? false
927
+        if didSave {
928
+            reloadChargedDevices()
929
+        }
930
+        return didSave
931
+    }
932
+
738 933
     @discardableResult
739 934
     func flushChargeInsights() -> Bool {
740 935
         let didFlushObservations = flushAllPendingChargeObservations()
@@ -1051,6 +1246,7 @@ final class AppData : ObservableObject {
1051 1246
         }
1052 1247
 
1053 1248
         let standbyMeasurementsByChargerID = chargerStandbyPowerStore.measurementsByChargerID()
1249
+        let consumptionSessionsByDeviceID = consumptionMonitorStore.sessionsByDeviceID()
1054 1250
         let readStore = chargeInsightsReadStore ?? chargeInsightsStore
1055 1251
         chargedDevicesReloadInFlight = true
1056 1252
         chargedDevicesReloadPending = false
@@ -1060,15 +1256,17 @@ final class AppData : ObservableObject {
1060 1256
 
1061 1257
             readStore?.resetContext()
1062 1258
             let summaries = (readStore?.fetchChargedDeviceSummaries() ?? []).map { chargedDevice in
1063
-                chargedDevice.withStandbyPowerMeasurements(
1064
-                    standbyMeasurementsByChargerID[chargedDevice.id] ?? []
1065
-                )
1259
+                chargedDevice
1260
+                    .withStandbyPowerMeasurements(standbyMeasurementsByChargerID[chargedDevice.id] ?? [])
1261
+                    .withConsumptionSessions(consumptionSessionsByDeviceID[chargedDevice.id] ?? [])
1066 1262
             }
1263
+            let powerbankSummaries = readStore?.fetchPowerbankSummaries() ?? []
1067 1264
 
1068 1265
             DispatchQueue.main.async { [weak self] in
1069 1266
                 guard let self else { return }
1070 1267
 
1071 1268
                 self.chargedDevices = summaries
1269
+                self.powerbanks = powerbankSummaries
1072 1270
                 self.chargeNotificationCoordinator.process(chargedDevices: self.deviceSummaries)
1073 1271
                 for meter in self.meters.values {
1074 1272
                     self.restoreChargeMonitoringStateIfNeeded(for: meter)
@@ -1248,27 +1446,9 @@ final class AppData : ObservableObject {
1248 1446
         return storedEnergyWh
1249 1447
     }
1250 1448
 
1251
-    private func displayedSessionChargeAh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
1252
-        let storedChargeAh = session.measuredChargeAh
1253
-        guard session.isTrimmed == false else {
1254
-            return storedChargeAh
1255
-        }
1256
-        guard session.status.isOpen else {
1257
-            return storedChargeAh
1258
-        }
1259
-
1260
-        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
1261
-            return storedChargeAh
1262
-        }
1263
-
1264
-        if let baselineChargeAh = session.meterChargeBaselineAh {
1265
-            return max(storedChargeAh, max(meter.recordedAH - baselineChargeAh, 0))
1266
-        }
1267
-
1268
-        return storedChargeAh
1269
-    }
1270 1449
 }
1271 1450
 
1451
+
1272 1452
 extension AppData.MeterSummary {
1273 1453
     var tint: Color {
1274 1454
         switch modelSummary {
+124 -17
USB Meter/Model/BluetoothManager.swift
@@ -7,11 +7,15 @@
7 7
 //
8 8
 
9 9
 import CoreBluetooth
10
+import OSLog
11
+
12
+private let bluetoothDiscoveryLogger = Logger(subsystem: "ro.xdev.USB-Meter", category: "BluetoothDiscovery")
10 13
 
11 14
 class BluetoothManager : NSObject, ObservableObject {
12 15
     private var manager: CBCentralManager?
13 16
     private var isStarting = false
14 17
     private var advertisementDataCache = AdvertisementDataCache()
18
+    private var lastDiscoveryLog = [String: Date]()
15 19
     @Published var managerState = CBManagerState.unknown
16 20
     @Published private(set) var scanStartedAt: Date?
17 21
     
@@ -45,29 +49,50 @@ class BluetoothManager : NSObject, ObservableObject {
45 49
             return
46 50
         }
47 51
         //manager.scanForPeripherals(withServices: allBluetoothRadioServices(), options: [ CBCentralManagerScanOptionAllowDuplicatesKey: true ])
48
-        manager.scanForPeripherals(withServices: allBluetoothRadioServices(), options: [ CBCentralManagerScanOptionAllowDuplicatesKey: true ])
52
+        let serviceUUIDs = allBluetoothRadioServices()
53
+        track("Scanning for USB meters with services: \(serviceUUIDs.map(\.uuidString).joined(separator: ", "))")
54
+        manager.scanForPeripherals(withServices: serviceUUIDs, options: [ CBCentralManagerScanOptionAllowDuplicatesKey: true ])
49 55
     }
50 56
     
51 57
     func discoveredMeter(peripheral: CBPeripheral, advertising advertismentData: [String : Any], rssi RSSI: NSNumber) {
52
-        guard let peripheralName = resolvedPeripheralName(for: peripheral, advertising: advertismentData) else {
53
-            return
54
-        }
55
-        guard let manufacturerData = resolvedManufacturerData(from: advertismentData), manufacturerData.count > 2 else {
58
+        logDiscoveryCandidate(peripheral: peripheral, advertising: advertismentData, rssi: RSSI)
59
+        let peripheralName = resolvedPeripheralName(for: peripheral, advertising: advertismentData)
60
+        guard let match = resolvedModel(for: peripheralName, advertising: advertismentData) else {
61
+            let reason: String
62
+            if let peripheralName {
63
+                reason = "unrecognized peripheral name '\(peripheralName)'; known names: \(Model.knownPeripheralNames.joined(separator: ", "))"
64
+            } else {
65
+                reason = "missing peripheral name/local name"
66
+            }
67
+            logDiscoveryRejection(
68
+                peripheral: peripheral,
69
+                reason: reason,
70
+                advertising: advertismentData,
71
+                rssi: RSSI
72
+            )
56 73
             return
57 74
         }
58
-        
59
-        guard let model = Model.byPeripheralName[peripheralName] else {
75
+        let model = match.model
76
+        let radio = match.radio
77
+        let advertisedName = match.advertisedName
78
+
79
+        guard let macAddress = resolvedMACAddress(from: advertismentData) else {
80
+            logDiscoveryRejection(
81
+                peripheral: peripheral,
82
+                reason: "missing or short manufacturer data for '\(advertisedName)'",
83
+                advertising: advertismentData,
84
+                rssi: RSSI
85
+            )
60 86
             return
61 87
         }
62 88
         
63
-        let macAddress = MACAddress(from: manufacturerData.suffix(from: 2))
64 89
         let macAddressString = macAddress.description
65
-        appData.registerMeter(macAddress: macAddressString, modelName: model.canonicalName, advertisedName: peripheralName)
90
+        appData.registerMeter(macAddress: macAddressString, modelName: model.canonicalName, advertisedName: advertisedName)
66 91
         appData.noteMeterSeen(at: Date(), macAddress: macAddressString)
67 92
         
68 93
         if appData.meters[peripheral.identifier] == nil {
69
-            track("adding new USB Meter named '\(peripheralName)' with MAC Address: '\(macAddress)'")
70
-            let btSerial = BluetoothSerial(peripheral: peripheral, radio: model.radio, with: macAddress, managedBy: manager!, RSSI: RSSI.intValue)
94
+            logDiscovery("BLE discovery accepted: model='\(model.canonicalName)', radio='\(radio)', advertisedName='\(advertisedName)', match='\(match.reason)', macAddress='\(macAddressString)'. \(discoveryDescription(peripheral: peripheral, advertising: advertismentData, rssi: RSSI))")
95
+            let btSerial = BluetoothSerial(peripheral: peripheral, radio: radio, with: macAddress, managedBy: manager!, RSSI: RSSI.intValue)
71 96
             var m = appData.meters
72 97
             let meter = Meter(model: model, with: btSerial)
73 98
             m[peripheral.identifier] = meter
@@ -85,6 +110,89 @@ class BluetoothManager : NSObject, ObservableObject {
85 110
             }
86 111
         }
87 112
     }
113
+
114
+    private func resolvedModel(for peripheralName: String?, advertising advertismentData: [String: Any]) -> (model: Model, advertisedName: String, radio: BluetoothRadio, reason: String)? {
115
+        if let peripheralName {
116
+            if let model = Model.model(forPeripheralName: peripheralName) {
117
+                return (model, peripheralName, radio(for: model, peripheralName: peripheralName), "recognized peripheral name")
118
+            }
119
+        }
120
+
121
+        return nil
122
+    }
123
+
124
+    private func radio(for model: Model, peripheralName: String) -> BluetoothRadio {
125
+        guard model == .TC66C else {
126
+            return model.radio
127
+        }
128
+
129
+        if peripheralName.caseInsensitiveCompare("BT24-M") == .orderedSame {
130
+            return .BT24M
131
+        }
132
+
133
+        return model.radio
134
+    }
135
+
136
+    private func resolvedMACAddress(from advertismentData: [String: Any]) -> MACAddress? {
137
+        guard let manufacturerData = resolvedManufacturerData(from: advertismentData), manufacturerData.count > 2 else {
138
+            return nil
139
+        }
140
+        return MACAddress(from: manufacturerData.suffix(from: 2))
141
+    }
142
+
143
+    private func logDiscoveryCandidate(peripheral: CBPeripheral, advertising advertismentData: [String: Any], rssi RSSI: NSNumber) {
144
+        guard shouldLogDiscoveryDetails(for: peripheral.identifier) else { return }
145
+        logDiscovery("BLE discovery candidate: \(discoveryDescription(peripheral: peripheral, advertising: advertismentData, rssi: RSSI))")
146
+    }
147
+
148
+    private func logDiscoveryRejection(
149
+        peripheral: CBPeripheral,
150
+        reason: String,
151
+        advertising advertismentData: [String: Any],
152
+        rssi RSSI: NSNumber
153
+    ) {
154
+        guard shouldLogDiscoveryRejection(for: peripheral.identifier, reason: reason) else { return }
155
+        logDiscovery("BLE discovery rejected: \(reason). \(discoveryDescription(peripheral: peripheral, advertising: advertismentData, rssi: RSSI))")
156
+    }
157
+
158
+    private func logDiscovery(_ message: String) {
159
+        track(message)
160
+        guard debugLogFlagEnabled("USB_METER_BLUETOOTH_LOGS") else { return }
161
+        bluetoothDiscoveryLogger.debug("\(message, privacy: .public)")
162
+    }
163
+    
164
+    private func shouldLogDiscoveryDetails(for identifier: UUID) -> Bool {
165
+        guard debugLogFlagEnabled("USB_METER_BLUETOOTH_LOGS") else {
166
+            return false
167
+        }
168
+        return shouldLogDiscoveryDetails(for: identifier.uuidString)
169
+    }
170
+
171
+    private func shouldLogDiscoveryRejection(for identifier: UUID, reason: String) -> Bool {
172
+        shouldLogDiscoveryDetails(for: "\(identifier.uuidString):\(reason)")
173
+    }
174
+
175
+    private func shouldLogDiscoveryDetails(for key: String) -> Bool {
176
+        let now = Date()
177
+        if let lastLoggedAt = lastDiscoveryLog[key], now.timeIntervalSince(lastLoggedAt) < 5 {
178
+            return false
179
+        }
180
+        lastDiscoveryLog[key] = now
181
+        return true
182
+    }
183
+
184
+    private func discoveryDescription(peripheral: CBPeripheral, advertising advertismentData: [String: Any], rssi RSSI: NSNumber) -> String {
185
+        let localName = advertismentData[CBAdvertisementDataLocalNameKey] as? String
186
+        let services = serviceUUIDs(from: advertismentData, key: CBAdvertisementDataServiceUUIDsKey).map(\.uuidString).joined(separator: ", ")
187
+        let overflowServices = serviceUUIDs(from: advertismentData, key: CBAdvertisementDataOverflowServiceUUIDsKey).map(\.uuidString).joined(separator: ", ")
188
+        let solicitedServices = serviceUUIDs(from: advertismentData, key: CBAdvertisementDataSolicitedServiceUUIDsKey).map(\.uuidString).joined(separator: ", ")
189
+        let manufacturerData = resolvedManufacturerData(from: advertismentData)
190
+        let manufacturerSummary = manufacturerData.map { "\($0.count)b \($0.hexEncodedStringValue)" } ?? "nil"
191
+        let txPower = advertismentData[CBAdvertisementDataTxPowerLevelKey].map { "\($0)" } ?? "nil"
192
+        let connectable = advertismentData[CBAdvertisementDataIsConnectable].map { "\($0)" } ?? "nil"
193
+
194
+        return "id='\(peripheral.identifier)', peripheralName='\(peripheral.name ?? "nil")', localName='\(localName ?? "nil")', resolvedName='\(resolvedPeripheralName(for: peripheral, advertising: advertismentData) ?? "nil")', rssi=\(RSSI), connectable=\(connectable), txPower=\(txPower), services=[\(services)], overflowServices=[\(overflowServices)], solicitedServices=[\(solicitedServices)], manufacturerData=\(manufacturerSummary)"
195
+    }
88 196
     
89 197
     private func resolvedPeripheralName(for peripheral: CBPeripheral, advertising advertismentData: [String : Any]) -> String? {
90 198
         let candidates = [
@@ -100,6 +208,10 @@ class BluetoothManager : NSObject, ObservableObject {
100 208
         
101 209
         return nil
102 210
     }
211
+
212
+    private func serviceUUIDs(from advertismentData: [String : Any], key: String) -> [CBUUID] {
213
+        advertismentData[key] as? [CBUUID] ?? []
214
+    }
103 215
     
104 216
     private func resolvedManufacturerData(from advertismentData: [String : Any]) -> Data? {
105 217
         if let data = advertismentData[CBAdvertisementDataManufacturerDataKey] as? Data {
@@ -116,7 +228,6 @@ extension BluetoothManager : CBCentralManagerDelegate {
116 228
     // MARK:  CBCentralManager state Changed
117 229
     func centralManagerDidUpdateState(_ central: CBCentralManager) {
118 230
         managerState = central.state;
119
-        track("\(central.state)")
120 231
         for meter in appData.meters.values {
121 232
             meter.btSerial.centralStateChanged(to: central.state)
122 233
         }
@@ -125,10 +236,8 @@ extension BluetoothManager : CBCentralManagerDelegate {
125 236
         case .poweredOff:
126 237
             scanStartedAt = nil
127 238
             advertisementDataCache.clear()
128
-            track("Bluetooth is Off. How should I behave?")
129 239
         case .poweredOn:
130 240
             scanStartedAt = Date()
131
-            track("Bluetooth is On... Start scanning...")
132 241
             // note that "didDisconnectPeripheral" won't be called if BLE is turned off while connected
133 242
             // connectedPeripheral = nil
134 243
             // pendingPeripheral = nil
@@ -136,7 +245,7 @@ extension BluetoothManager : CBCentralManagerDelegate {
136 245
         case .resetting:
137 246
             scanStartedAt = nil
138 247
             advertisementDataCache.clear()
139
-            track("Bluetooth is reseting... . Whatever that means.")
248
+            track("Bluetooth is resetting.")
140 249
         case .unauthorized:
141 250
             scanStartedAt = nil
142 251
             advertisementDataCache.clear()
@@ -159,13 +268,11 @@ extension BluetoothManager : CBCentralManagerDelegate {
159 268
     // MARK:  CBCentralManager didDiscover peripheral
160 269
     func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
161 270
         let completeAdvertisementData = self.advertisementDataCache.append(peripheral: peripheral, advertisementData: advertisementData)
162
-        //track("Device discoverded UUID: '\(peripheral.identifier)' named '\(peripheral.name ?? "Unknown")'); RSSI: \(RSSI) dBm; Advertisment data: \(advertisementData)")
163 271
         discoveredMeter(peripheral: peripheral, advertising: completeAdvertisementData, rssi: RSSI )
164 272
     }
165 273
     
166 274
     // MARK:  CBCentralManager didConnect peripheral
167 275
     internal func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
168
-        //track("Connected to peripheral: '\(peripheral.identifier)'")
169 276
         if let usbMeter = appData.meters[peripheral.identifier] {
170 277
             usbMeter.btSerial.connectionEstablished()
171 278
         }
+9 -3
USB Meter/Model/BluetoothRadio.swift
@@ -15,10 +15,13 @@ import CoreBluetooth
15 15
  - Code [HM10 Bluetooth Serial iOS](https://github.com/hoiberg/HM10-BluetoothSerial-iOS)
16 16
  # PW0316
17 17
  - Documentation [PW0316 BLE4.0 User Manual](http://www.phangwei.com/o/PW0316_User_Manual_V2.9.pdf)
18
+ # BT24-M
19
+ - Seen in newer TC66C units as a transparent BLE serial module on `FFE0`
18 20
  */
19 21
 enum BluetoothRadio : CaseIterable {
20 22
     case BT18
21 23
     case PW0316
24
+    case BT24M
22 25
     case UNKNOWN
23 26
 }
24 27
 
@@ -27,7 +30,8 @@ enum BluetoothRadio : CaseIterable {
27 30
  */
28 31
 var BluetoothRadioServicesUUIDS: [BluetoothRadio:[CBUUID]] = [
29 32
     .BT18 : [CBUUID(string: "FFE0")],
30
-    .PW0316 : [CBUUID(string: "FFE0"), CBUUID(string: "FFE5")]
33
+    .PW0316 : [CBUUID(string: "FFE0"), CBUUID(string: "FFE5")],
34
+    .BT24M : [CBUUID(string: "FFE0")]
31 35
 ]
32 36
 
33 37
 /**
@@ -40,7 +44,8 @@ var BluetoothRadioServicesUUIDS: [BluetoothRadio:[CBUUID]] = [
40 44
  */
41 45
 var BluetoothRadioNotifyUUIDs: [BluetoothRadio:[CBUUID]] = [
42 46
     .BT18 : [CBUUID(string: "FFE1")],
43
-    .PW0316 : [CBUUID(string: "FFE4")]
47
+    .PW0316 : [CBUUID(string: "FFE4")],
48
+    .BT24M : [CBUUID(string: "FFE1")]
44 49
 ]
45 50
 
46 51
 /**
@@ -52,7 +57,8 @@ var BluetoothRadioNotifyUUIDs: [BluetoothRadio:[CBUUID]] = [
52 57
  */
53 58
 var BluetoothRadioWriteUUIDs: [BluetoothRadio:[CBUUID]] = [
54 59
     .BT18 : [CBUUID(string: "FFE2"), CBUUID(string: "FFE1")],
55
-    .PW0316 : [CBUUID(string: "FFE9")]
60
+    .PW0316 : [CBUUID(string: "FFE9")],
61
+    .BT24M : [CBUUID(string: "FFE1")]
56 62
 ]
57 63
 
58 64
 /**
+4 -15
USB Meter/Model/BluetoothSerial.swift
@@ -155,20 +155,17 @@ final class BluetoothSerial : NSObject, ObservableObject {
155 155
      - parameter expectedResponseLength: Optional If message sent require a respnse the length for that response must be provideed. Incomming data will be buffered before calling delegate.didReceiveData
156 156
      */
157 157
     func write(_ data: Data, expectedResponseLength: Int = 0) {
158
-        //track("\(self.expectedResponseLength)")
159
-        //track(data.hexEncodedStringValue)
160 158
         guard operationalState == .peripheralReady else {
161
-            track("Guard: \(operationalState)")
159
+            track("Write skipped while peripheral state is \(operationalState)")
162 160
             return
163 161
         }
164 162
         guard self.expectedResponseLength == 0 else {
165
-            track("Guard: \(self.expectedResponseLength)")
163
+            track("Write skipped while waiting for \(self.expectedResponseLength) response bytes")
166 164
             return
167 165
         }
168 166
         
169 167
         self.expectedResponseLength = expectedResponseLength
170 168
         
171
-//        track("Sending...")
172 169
         guard let writeCharacteristic else {
173 170
             track("Missing write characteristic for \(radio)")
174 171
             self.expectedResponseLength = 0
@@ -177,7 +174,6 @@ final class BluetoothSerial : NSObject, ObservableObject {
177 174
 
178 175
         let writeType: CBCharacteristicWriteType = writeCharacteristic.properties.contains(.writeWithoutResponse) ? .withoutResponse : .withResponse
179 176
         peripheral.writeValue(data, for: writeCharacteristic, type: writeType)
180
-//        track("Sent!")
181 177
         if self.expectedResponseLength != 0 {
182 178
             setWDT()
183 179
         }
@@ -299,12 +295,11 @@ extension BluetoothSerial : CBPeripheralDelegate {
299 295
     
300 296
     //  MARK:   didDiscoverServices
301 297
     func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
302
-        track("\(String(describing: peripheral.services))")
303 298
         if error != nil {
304 299
             track( "Error: \(error!)" )
305 300
         }
306 301
         switch radio {
307
-        case .BT18:
302
+        case .BT18, .BT24M:
308 303
             for service in peripheral.services! {
309 304
                 switch service.uuid {
310 305
                 case CBUUID(string: "FFE0"):
@@ -334,9 +329,8 @@ extension BluetoothSerial : CBPeripheralDelegate {
334 329
         if error != nil {
335 330
             track( "Error: \(error!)" )
336 331
         }
337
-        track("\(String(describing: service.characteristics))")
338 332
         switch radio {
339
-        case .BT18:
333
+        case .BT18, .BT24M:
340 334
             updateBT18Characteristics(for: service)
341 335
         case .PW0316:
342 336
             updatePW0316Characteristics(for: service)
@@ -357,7 +351,6 @@ extension BluetoothSerial : CBPeripheralDelegate {
357 351
     
358 352
     //  MARK:   didUpdateValueFor
359 353
     func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
360
-//        track("")
361 354
         if error != nil {
362 355
             track( "Error: \(error!)" )
363 356
         }
@@ -378,14 +371,11 @@ extension BluetoothSerial : CBPeripheralDelegate {
378 371
 
379 372
         let previousBufferCount = buffer.count
380 373
         buffer.append(incomingData)
381
-//        track("\n\(buffer.hexEncodedStringValue)")
382 374
         switch buffer.count {
383 375
         case let x where x < expectedResponseLength:
384 376
             setWDT()
385
-            //track("buffering")
386 377
             break;
387 378
         case let x where x == expectedResponseLength:
388
-            //track("buffer ready")
389 379
             wdTimer?.invalidate()
390 380
             expectedResponseLength = 0
391 381
             delegate?.didReceiveData(buffer)
@@ -405,7 +395,6 @@ extension BluetoothSerial : CBPeripheralDelegate {
405 395
     }
406 396
     
407 397
     func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
408
-        //track("")
409 398
     }
410 399
     
411 400
 }
+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 16.xcdatamodel</string>
6
+	<string>USB_Meter 20.xcdatamodel</string>
7 7
 </dict>
8 8
 </plist>
+126 -0
USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 17.xcdatamodel/contents
@@ -0,0 +1,126 @@
1
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2
+<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23657" systemVersion="24G81" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES">
3
+    <entity name="ChargedDevice" representedClassName="ChargedDevice" syncable="YES" codeGenerationType="class">
4
+        <attribute name="id" optional="YES" attributeType="String"/>
5
+        <attribute name="name" optional="YES" attributeType="String"/>
6
+        <attribute name="deviceClassRawValue" optional="YES" attributeType="String"/>
7
+        <attribute name="deviceTemplateID" optional="YES" attributeType="String"/>
8
+        <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
9
+        <attribute name="chargingStateAvailabilityRawValue" optional="YES" attributeType="String"/>
10
+        <attribute name="supportsWiredCharging" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
11
+        <attribute name="supportsWirelessCharging" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
12
+        <attribute name="preferredChargingTransportRawValue" optional="YES" attributeType="String"/>
13
+        <attribute name="wirelessChargingProfileRawValue" optional="YES" attributeType="String"/>
14
+        <attribute name="configuredCompletionCurrentsRawValue" optional="YES" attributeType="String"/>
15
+        <attribute name="learnedCompletionCurrentsRawValue" optional="YES" attributeType="String"/>
16
+        <attribute name="wiredChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
17
+        <attribute name="wirelessChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
18
+        <attribute name="minimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
19
+        <attribute name="estimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
20
+        <attribute name="wiredMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
21
+        <attribute name="wirelessMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
22
+        <attribute name="wiredEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
23
+        <attribute name="wirelessEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
24
+        <attribute name="wirelessChargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
25
+        <attribute name="chargerObservedVoltageSelectionsRawValue" optional="YES" attributeType="String"/>
26
+        <attribute name="chargerIdleCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
27
+        <attribute name="chargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
28
+        <attribute name="chargerMaximumPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
29
+        <attribute name="qrIdentifier" optional="YES" attributeType="String"/>
30
+        <attribute name="notes" optional="YES" attributeType="String"/>
31
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
32
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
33
+    </entity>
34
+    <entity name="ChargeSession" representedClassName="ChargeSession" syncable="YES" codeGenerationType="class">
35
+        <attribute name="id" optional="YES" attributeType="String"/>
36
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
37
+        <attribute name="chargerID" optional="YES" attributeType="String"/>
38
+        <attribute name="meterMACAddress" optional="YES" attributeType="String"/>
39
+        <attribute name="meterName" optional="YES" attributeType="String"/>
40
+        <attribute name="meterModel" optional="YES" attributeType="String"/>
41
+        <attribute name="startedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
42
+        <attribute name="endedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
43
+        <attribute name="lastObservedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
44
+        <attribute name="pausedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
45
+        <attribute name="statusRawValue" optional="YES" attributeType="String"/>
46
+        <attribute name="sourceModeRawValue" optional="YES" attributeType="String"/>
47
+        <attribute name="chargingTransportRawValue" optional="YES" attributeType="String"/>
48
+        <attribute name="chargingStateRawValue" optional="YES" attributeType="String"/>
49
+        <attribute name="autoStopEnabled" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
50
+        <attribute name="selectedDataGroup" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
51
+        <attribute name="meterEnergyBaselineWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
52
+        <attribute name="meterChargeBaselineAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
53
+        <attribute name="meterDurationBaselineSeconds" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
54
+        <attribute name="meterLastEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
55
+        <attribute name="meterLastChargeAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
56
+        <attribute name="meterLastDurationSeconds" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
57
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
58
+        <attribute name="effectiveBatteryEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
59
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
60
+        <attribute name="minimumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
61
+        <attribute name="maximumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
62
+        <attribute name="maximumObservedPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
63
+        <attribute name="maximumObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
64
+        <attribute name="selectedSourceVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
65
+        <attribute name="completionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
66
+        <attribute name="stopThresholdAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
67
+        <attribute name="startBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
68
+        <attribute name="endBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
69
+        <attribute name="capacityEstimateWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
70
+        <attribute name="wirelessEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
71
+        <attribute name="usesEstimatedWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
72
+        <attribute name="shouldWarnAboutLowWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
73
+        <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
74
+        <attribute name="usedOfflineMeterCounters" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
75
+        <attribute name="belowThresholdSince" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
76
+        <attribute name="lastObservedCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
77
+        <attribute name="lastObservedPowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
78
+        <attribute name="lastObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
79
+        <attribute name="hasObservedChargeFlow" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
80
+        <attribute name="targetBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
81
+        <attribute name="targetBatteryAlertTriggeredAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
82
+        <attribute name="requiresCompletionConfirmation" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
83
+        <attribute name="completionConfirmationRequestedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
84
+        <attribute name="completionContradictionPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
85
+        <attribute name="completionConfirmationCooldownUntil" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
86
+        <attribute name="trimStart" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
87
+        <attribute name="trimEnd" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
88
+        <attribute name="wasConflictHealed" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
89
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
90
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
91
+    </entity>
92
+    <entity name="ChargeCheckpoint" representedClassName="ChargeCheckpoint" syncable="YES" codeGenerationType="class">
93
+        <attribute name="id" optional="YES" attributeType="String"/>
94
+        <attribute name="sessionID" optional="YES" attributeType="String"/>
95
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
96
+        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
97
+        <attribute name="batteryPercent" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
98
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
99
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
100
+        <attribute name="currentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
101
+        <attribute name="voltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
102
+        <attribute name="label" optional="YES" attributeType="String"/>
103
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
104
+    </entity>
105
+    <entity name="ChargeSessionSample" representedClassName="ChargeSessionSample" syncable="NO" codeGenerationType="class">
106
+        <attribute name="id" optional="YES" attributeType="String"/>
107
+        <attribute name="sessionID" optional="YES" attributeType="String"/>
108
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
109
+        <attribute name="bucketIndex" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
110
+        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
111
+        <attribute name="averageCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
112
+        <attribute name="averageVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
113
+        <attribute name="averagePowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
114
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
115
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
116
+        <attribute name="sampleCount" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
117
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
118
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
119
+    </entity>
120
+    <elements>
121
+        <element name="ChargedDevice" positionX="-72" positionY="-36" width="128" height="433"/>
122
+        <element name="ChargeSession" positionX="136" positionY="-36" width="128" height="883"/>
123
+        <element name="ChargeCheckpoint" positionX="344" positionY="-36" width="128" height="208"/>
124
+        <element name="ChargeSessionSample" positionX="552" positionY="-36" width="128" height="253"/>
125
+    </elements>
126
+</model>
+127 -0
USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 18.xcdatamodel/contents
@@ -0,0 +1,127 @@
1
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2
+<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23657" systemVersion="24G81" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES">
3
+    <entity name="ChargedDevice" representedClassName="ChargedDevice" syncable="YES" codeGenerationType="class">
4
+        <attribute name="id" optional="YES" attributeType="String"/>
5
+        <attribute name="name" optional="YES" attributeType="String"/>
6
+        <attribute name="deviceClassRawValue" optional="YES" attributeType="String"/>
7
+        <attribute name="deviceTemplateID" optional="YES" attributeType="String"/>
8
+        <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
9
+        <attribute name="chargingStateAvailabilityRawValue" optional="YES" attributeType="String"/>
10
+        <attribute name="supportsWiredCharging" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
11
+        <attribute name="supportsWirelessCharging" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
12
+        <attribute name="preferredChargingTransportRawValue" optional="YES" attributeType="String"/>
13
+        <attribute name="wirelessChargingProfileRawValue" optional="YES" attributeType="String"/>
14
+        <attribute name="configuredCompletionCurrentsRawValue" optional="YES" attributeType="String"/>
15
+        <attribute name="learnedCompletionCurrentsRawValue" optional="YES" attributeType="String"/>
16
+        <attribute name="wiredChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
17
+        <attribute name="wirelessChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
18
+        <attribute name="minimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
19
+        <attribute name="estimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
20
+        <attribute name="wiredMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
21
+        <attribute name="wirelessMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
22
+        <attribute name="wiredEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
23
+        <attribute name="wirelessEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
24
+        <attribute name="wirelessChargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
25
+        <attribute name="chargerObservedVoltageSelectionsRawValue" optional="YES" attributeType="String"/>
26
+        <attribute name="chargerIdleCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
27
+        <attribute name="chargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
28
+        <attribute name="chargerMaximumPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
29
+        <attribute name="qrIdentifier" optional="YES" attributeType="String"/>
30
+        <attribute name="notes" optional="YES" attributeType="String"/>
31
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
32
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
33
+    </entity>
34
+    <entity name="ChargeSession" representedClassName="ChargeSession" syncable="YES" codeGenerationType="class">
35
+        <attribute name="id" optional="YES" attributeType="String"/>
36
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
37
+        <attribute name="chargerID" optional="YES" attributeType="String"/>
38
+        <attribute name="meterMACAddress" optional="YES" attributeType="String"/>
39
+        <attribute name="meterName" optional="YES" attributeType="String"/>
40
+        <attribute name="meterModel" optional="YES" attributeType="String"/>
41
+        <attribute name="startedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
42
+        <attribute name="endedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
43
+        <attribute name="lastObservedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
44
+        <attribute name="pausedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
45
+        <attribute name="statusRawValue" optional="YES" attributeType="String"/>
46
+        <attribute name="sourceModeRawValue" optional="YES" attributeType="String"/>
47
+        <attribute name="chargingTransportRawValue" optional="YES" attributeType="String"/>
48
+        <attribute name="chargingStateRawValue" optional="YES" attributeType="String"/>
49
+        <attribute name="autoStopEnabled" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
50
+        <attribute name="selectedDataGroup" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
51
+        <attribute name="meterEnergyBaselineWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
52
+        <attribute name="meterChargeBaselineAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
53
+        <attribute name="meterDurationBaselineSeconds" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
54
+        <attribute name="meterLastEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
55
+        <attribute name="meterLastChargeAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
56
+        <attribute name="meterLastDurationSeconds" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
57
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
58
+        <attribute name="effectiveBatteryEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
59
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
60
+        <attribute name="minimumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
61
+        <attribute name="maximumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
62
+        <attribute name="maximumObservedPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
63
+        <attribute name="maximumObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
64
+        <attribute name="selectedSourceVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
65
+        <attribute name="completionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
66
+        <attribute name="stopThresholdAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
67
+        <attribute name="startBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
68
+        <attribute name="endBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
69
+        <attribute name="capacityEstimateWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
70
+        <attribute name="wirelessEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
71
+        <attribute name="usesEstimatedWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
72
+        <attribute name="shouldWarnAboutLowWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
73
+        <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
74
+        <attribute name="usedOfflineMeterCounters" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
75
+        <attribute name="belowThresholdSince" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
76
+        <attribute name="lastObservedCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
77
+        <attribute name="lastObservedPowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
78
+        <attribute name="lastObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
79
+        <attribute name="hasObservedChargeFlow" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
80
+        <attribute name="targetBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
81
+        <attribute name="targetBatteryAlertTriggeredAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
82
+        <attribute name="requiresCompletionConfirmation" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
83
+        <attribute name="completionConfirmationRequestedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
84
+        <attribute name="completionContradictionPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
85
+        <attribute name="completionConfirmationCooldownUntil" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
86
+        <attribute name="trimStart" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
87
+        <attribute name="trimEnd" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
88
+        <attribute name="wasConflictHealed" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
89
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
90
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
91
+    </entity>
92
+    <entity name="ChargeCheckpoint" representedClassName="ChargeCheckpoint" syncable="YES" codeGenerationType="class">
93
+        <attribute name="id" optional="YES" attributeType="String"/>
94
+        <attribute name="sessionID" optional="YES" attributeType="String"/>
95
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
96
+        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
97
+        <attribute name="batteryPercent" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
98
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
99
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
100
+        <attribute name="currentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
101
+        <attribute name="voltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
102
+        <attribute name="label" optional="YES" attributeType="String"/>
103
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
104
+    </entity>
105
+    <entity name="ChargeSessionSample" representedClassName="ChargeSessionSample" syncable="NO" codeGenerationType="class">
106
+        <attribute name="id" optional="YES" attributeType="String"/>
107
+        <attribute name="sessionID" optional="YES" attributeType="String"/>
108
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
109
+        <attribute name="bucketIndex" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
110
+        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
111
+        <attribute name="averageCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
112
+        <attribute name="averageVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
113
+        <attribute name="averagePowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
114
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
115
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
116
+        <attribute name="estimatedBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
117
+        <attribute name="sampleCount" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
118
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
119
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
120
+    </entity>
121
+    <elements>
122
+        <element name="ChargedDevice" positionX="-72" positionY="-36" width="128" height="433"/>
123
+        <element name="ChargeSession" positionX="136" positionY="-36" width="128" height="883"/>
124
+        <element name="ChargeCheckpoint" positionX="344" positionY="-36" width="128" height="208"/>
125
+        <element name="ChargeSessionSample" positionX="552" positionY="-36" width="128" height="268"/>
126
+    </elements>
127
+</model>
+152 -0
USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 19.xcdatamodel/contents
@@ -0,0 +1,152 @@
1
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2
+<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23657" systemVersion="24G81" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES">
3
+    <entity name="ChargedDevice" representedClassName="ChargedDevice" syncable="YES" codeGenerationType="class">
4
+        <attribute name="id" optional="YES" attributeType="String"/>
5
+        <attribute name="name" optional="YES" attributeType="String"/>
6
+        <attribute name="deviceClassRawValue" optional="YES" attributeType="String"/>
7
+        <attribute name="deviceTemplateID" optional="YES" attributeType="String"/>
8
+        <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
9
+        <attribute name="chargingStateAvailabilityRawValue" optional="YES" attributeType="String"/>
10
+        <attribute name="supportsWiredCharging" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
11
+        <attribute name="supportsWirelessCharging" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
12
+        <attribute name="preferredChargingTransportRawValue" optional="YES" attributeType="String"/>
13
+        <attribute name="wirelessChargingProfileRawValue" optional="YES" attributeType="String"/>
14
+        <attribute name="configuredCompletionCurrentsRawValue" optional="YES" attributeType="String"/>
15
+        <attribute name="learnedCompletionCurrentsRawValue" optional="YES" attributeType="String"/>
16
+        <attribute name="wiredChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
17
+        <attribute name="wirelessChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
18
+        <attribute name="minimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
19
+        <attribute name="estimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
20
+        <attribute name="wiredMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
21
+        <attribute name="wirelessMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
22
+        <attribute name="wiredEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
23
+        <attribute name="wirelessEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
24
+        <attribute name="wirelessChargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
25
+        <attribute name="chargerObservedVoltageSelectionsRawValue" optional="YES" attributeType="String"/>
26
+        <attribute name="chargerIdleCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
27
+        <attribute name="chargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
28
+        <attribute name="chargerMaximumPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
29
+        <attribute name="qrIdentifier" optional="YES" attributeType="String"/>
30
+        <attribute name="notes" optional="YES" attributeType="String"/>
31
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
32
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
33
+    </entity>
34
+    <entity name="Powerbank" representedClassName="Powerbank" syncable="YES" codeGenerationType="class">
35
+        <attribute name="id" optional="YES" attributeType="String"/>
36
+        <attribute name="name" optional="YES" attributeType="String"/>
37
+        <attribute name="qrIdentifier" optional="YES" attributeType="String"/>
38
+        <attribute name="deviceTemplateID" optional="YES" attributeType="String"/>
39
+        <attribute name="notes" optional="YES" attributeType="String"/>
40
+        <attribute name="batteryLevelReportingRawValue" optional="YES" attributeType="String"/>
41
+        <attribute name="batteryBarsCount" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
42
+        <attribute name="estimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
43
+        <attribute name="apparentCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
44
+        <attribute name="configuredCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
45
+        <attribute name="learnedCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
46
+        <attribute name="minimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
47
+        <attribute name="sourceObservedVoltageSelectionsRawValue" optional="YES" attributeType="String"/>
48
+        <attribute name="sourceIdleCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
49
+        <attribute name="sourceMaximumPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
50
+        <attribute name="sourceEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
51
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
52
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
53
+    </entity>
54
+    <entity name="ChargeSession" representedClassName="ChargeSession" syncable="YES" codeGenerationType="class">
55
+        <attribute name="id" optional="YES" attributeType="String"/>
56
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
57
+        <attribute name="chargedPowerbankID" optional="YES" attributeType="String"/>
58
+        <attribute name="chargerID" optional="YES" attributeType="String"/>
59
+        <attribute name="sourcePowerbankID" optional="YES" attributeType="String"/>
60
+        <attribute name="meterMACAddress" optional="YES" attributeType="String"/>
61
+        <attribute name="meterName" optional="YES" attributeType="String"/>
62
+        <attribute name="meterModel" optional="YES" attributeType="String"/>
63
+        <attribute name="startedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
64
+        <attribute name="endedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
65
+        <attribute name="lastObservedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
66
+        <attribute name="pausedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
67
+        <attribute name="statusRawValue" optional="YES" attributeType="String"/>
68
+        <attribute name="sourceModeRawValue" optional="YES" attributeType="String"/>
69
+        <attribute name="chargingTransportRawValue" optional="YES" attributeType="String"/>
70
+        <attribute name="chargingStateRawValue" optional="YES" attributeType="String"/>
71
+        <attribute name="autoStopEnabled" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
72
+        <attribute name="selectedDataGroup" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
73
+        <attribute name="meterEnergyBaselineWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
74
+        <attribute name="meterChargeBaselineAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
75
+        <attribute name="meterDurationBaselineSeconds" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
76
+        <attribute name="meterLastEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
77
+        <attribute name="meterLastChargeAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
78
+        <attribute name="meterLastDurationSeconds" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
79
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
80
+        <attribute name="effectiveBatteryEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
81
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
82
+        <attribute name="minimumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
83
+        <attribute name="maximumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
84
+        <attribute name="maximumObservedPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
85
+        <attribute name="maximumObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
86
+        <attribute name="selectedSourceVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
87
+        <attribute name="completionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
88
+        <attribute name="stopThresholdAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
89
+        <attribute name="startBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
90
+        <attribute name="endBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
91
+        <attribute name="capacityEstimateWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
92
+        <attribute name="wirelessEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
93
+        <attribute name="usesEstimatedWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
94
+        <attribute name="shouldWarnAboutLowWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
95
+        <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
96
+        <attribute name="usedOfflineMeterCounters" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
97
+        <attribute name="belowThresholdSince" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
98
+        <attribute name="lastObservedCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
99
+        <attribute name="lastObservedPowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
100
+        <attribute name="lastObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
101
+        <attribute name="hasObservedChargeFlow" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
102
+        <attribute name="targetBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
103
+        <attribute name="targetBatteryAlertTriggeredAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
104
+        <attribute name="requiresCompletionConfirmation" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
105
+        <attribute name="completionConfirmationRequestedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
106
+        <attribute name="completionContradictionPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
107
+        <attribute name="completionConfirmationCooldownUntil" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
108
+        <attribute name="trimStart" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
109
+        <attribute name="trimEnd" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
110
+        <attribute name="wasConflictHealed" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
111
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
112
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
113
+    </entity>
114
+    <entity name="ChargeCheckpoint" representedClassName="ChargeCheckpoint" syncable="YES" codeGenerationType="class">
115
+        <attribute name="id" optional="YES" attributeType="String"/>
116
+        <attribute name="sessionID" optional="YES" attributeType="String"/>
117
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
118
+        <attribute name="powerbankID" optional="YES" attributeType="String"/>
119
+        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
120
+        <attribute name="batteryPercent" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
121
+        <attribute name="batteryBarsValue" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
122
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
123
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
124
+        <attribute name="currentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
125
+        <attribute name="voltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
126
+        <attribute name="label" optional="YES" attributeType="String"/>
127
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
128
+    </entity>
129
+    <entity name="ChargeSessionSample" representedClassName="ChargeSessionSample" syncable="NO" codeGenerationType="class">
130
+        <attribute name="id" optional="YES" attributeType="String"/>
131
+        <attribute name="sessionID" optional="YES" attributeType="String"/>
132
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
133
+        <attribute name="bucketIndex" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
134
+        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
135
+        <attribute name="averageCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
136
+        <attribute name="averageVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
137
+        <attribute name="averagePowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
138
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
139
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
140
+        <attribute name="estimatedBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
141
+        <attribute name="sampleCount" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
142
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
143
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
144
+    </entity>
145
+    <elements>
146
+        <element name="ChargedDevice" positionX="-72" positionY="-36" width="128" height="433"/>
147
+        <element name="Powerbank" positionX="-72" positionY="420" width="128" height="313"/>
148
+        <element name="ChargeSession" positionX="136" positionY="-36" width="128" height="913"/>
149
+        <element name="ChargeCheckpoint" positionX="344" positionY="-36" width="128" height="238"/>
150
+        <element name="ChargeSessionSample" positionX="552" positionY="-36" width="128" height="268"/>
151
+    </elements>
152
+</model>
+179 -0
USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 20.xcdatamodel/contents
@@ -0,0 +1,179 @@
1
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2
+<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23657" systemVersion="24G81" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES">
3
+    <entity name="ChargedDevice" representedClassName="ChargedDevice" syncable="YES" codeGenerationType="class">
4
+        <attribute name="id" optional="YES" attributeType="String"/>
5
+        <attribute name="name" optional="YES" attributeType="String"/>
6
+        <attribute name="deviceClassRawValue" optional="YES" attributeType="String"/>
7
+        <attribute name="deviceTemplateID" optional="YES" attributeType="String"/>
8
+        <attribute name="profileID" optional="YES" attributeType="String"/>
9
+        <attribute name="hasInternalSubject" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
10
+        <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
11
+        <attribute name="chargingStateAvailabilityRawValue" optional="YES" attributeType="String"/>
12
+        <attribute name="supportsWiredCharging" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
13
+        <attribute name="supportsWirelessCharging" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
14
+        <attribute name="preferredChargingTransportRawValue" optional="YES" attributeType="String"/>
15
+        <attribute name="wirelessChargingProfileRawValue" optional="YES" attributeType="String"/>
16
+        <attribute name="configuredCompletionCurrentsRawValue" optional="YES" attributeType="String"/>
17
+        <attribute name="learnedCompletionCurrentsRawValue" optional="YES" attributeType="String"/>
18
+        <attribute name="wiredChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
19
+        <attribute name="wirelessChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
20
+        <attribute name="minimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
21
+        <attribute name="estimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
22
+        <attribute name="wiredMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
23
+        <attribute name="wirelessMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
24
+        <attribute name="wiredEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
25
+        <attribute name="wirelessEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
26
+        <attribute name="wirelessChargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
27
+        <attribute name="chargerObservedVoltageSelectionsRawValue" optional="YES" attributeType="String"/>
28
+        <attribute name="chargerIdleCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
29
+        <attribute name="chargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
30
+        <attribute name="chargerMaximumPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
31
+        <attribute name="qrIdentifier" optional="YES" attributeType="String"/>
32
+        <attribute name="notes" optional="YES" attributeType="String"/>
33
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
34
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
35
+    </entity>
36
+    <entity name="Powerbank" representedClassName="Powerbank" syncable="YES" codeGenerationType="class">
37
+        <attribute name="id" optional="YES" attributeType="String"/>
38
+        <attribute name="name" optional="YES" attributeType="String"/>
39
+        <attribute name="qrIdentifier" optional="YES" attributeType="String"/>
40
+        <attribute name="deviceTemplateID" optional="YES" attributeType="String"/>
41
+        <attribute name="profileID" optional="YES" attributeType="String"/>
42
+        <attribute name="notes" optional="YES" attributeType="String"/>
43
+        <attribute name="batteryLevelReportingRawValue" optional="YES" attributeType="String"/>
44
+        <attribute name="batteryBarsCount" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
45
+        <attribute name="estimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
46
+        <attribute name="apparentCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
47
+        <attribute name="configuredCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
48
+        <attribute name="learnedCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
49
+        <attribute name="minimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
50
+        <attribute name="sourceObservedVoltageSelectionsRawValue" optional="YES" attributeType="String"/>
51
+        <attribute name="sourceIdleCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
52
+        <attribute name="sourceMaximumPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
53
+        <attribute name="sourceEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
54
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
55
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
56
+    </entity>
57
+    <entity name="DeviceProfile" representedClassName="DeviceProfile" syncable="YES" codeGenerationType="class">
58
+        <attribute name="id" optional="YES" attributeType="String"/>
59
+        <attribute name="name" optional="YES" attributeType="String"/>
60
+        <attribute name="categoryRawValue" optional="YES" attributeType="String"/>
61
+        <attribute name="iconSymbolName" optional="YES" attributeType="String"/>
62
+        <attribute name="iconFallbackSymbolName" optional="YES" attributeType="String"/>
63
+        <attribute name="isCustom" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
64
+        <attribute name="schemaVersion" optional="YES" attributeType="Integer 16" defaultValueString="1" usesScalarValueType="YES"/>
65
+        <attribute name="sortOrder" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
66
+        <attribute name="group" optional="YES" attributeType="String"/>
67
+        <attribute name="capWiredCharging" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
68
+        <attribute name="capWirelessCharging" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
69
+        <attribute name="capWirelessProfilesRawValue" optional="YES" attributeType="String"/>
70
+        <attribute name="capChargingStateAvailabilityRawValue" optional="YES" attributeType="String"/>
71
+        <attribute name="capHasInternalSubject" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
72
+        <attribute name="defaultWirelessChargingProfileRawValue" optional="YES" attributeType="String"/>
73
+        <attribute name="defaultWiredMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
74
+        <attribute name="defaultWirelessMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
75
+        <attribute name="defaultWiredEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
76
+        <attribute name="defaultWirelessEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
77
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
78
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
79
+    </entity>
80
+    <entity name="ChargeSession" representedClassName="ChargeSession" syncable="YES" codeGenerationType="class">
81
+        <attribute name="id" optional="YES" attributeType="String"/>
82
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
83
+        <attribute name="chargedPowerbankID" optional="YES" attributeType="String"/>
84
+        <attribute name="chargerID" optional="YES" attributeType="String"/>
85
+        <attribute name="sourcePowerbankID" optional="YES" attributeType="String"/>
86
+        <attribute name="meterMACAddress" optional="YES" attributeType="String"/>
87
+        <attribute name="meterName" optional="YES" attributeType="String"/>
88
+        <attribute name="meterModel" optional="YES" attributeType="String"/>
89
+        <attribute name="startedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
90
+        <attribute name="endedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
91
+        <attribute name="lastObservedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
92
+        <attribute name="pausedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
93
+        <attribute name="statusRawValue" optional="YES" attributeType="String"/>
94
+        <attribute name="sourceModeRawValue" optional="YES" attributeType="String"/>
95
+        <attribute name="chargingTransportRawValue" optional="YES" attributeType="String"/>
96
+        <attribute name="chargingStateRawValue" optional="YES" attributeType="String"/>
97
+        <attribute name="autoStopEnabled" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
98
+        <attribute name="selectedDataGroup" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
99
+        <attribute name="meterEnergyBaselineWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
100
+        <attribute name="meterChargeBaselineAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
101
+        <attribute name="meterDurationBaselineSeconds" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
102
+        <attribute name="meterLastEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
103
+        <attribute name="meterLastChargeAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
104
+        <attribute name="meterLastDurationSeconds" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
105
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
106
+        <attribute name="effectiveBatteryEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
107
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
108
+        <attribute name="minimumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
109
+        <attribute name="maximumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
110
+        <attribute name="maximumObservedPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
111
+        <attribute name="maximumObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
112
+        <attribute name="selectedSourceVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
113
+        <attribute name="completionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
114
+        <attribute name="stopThresholdAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
115
+        <attribute name="startBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
116
+        <attribute name="endBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
117
+        <attribute name="capacityEstimateWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
118
+        <attribute name="wirelessEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
119
+        <attribute name="usesEstimatedWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
120
+        <attribute name="shouldWarnAboutLowWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
121
+        <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
122
+        <attribute name="usedOfflineMeterCounters" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
123
+        <attribute name="belowThresholdSince" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
124
+        <attribute name="lastObservedCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
125
+        <attribute name="lastObservedPowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
126
+        <attribute name="lastObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
127
+        <attribute name="hasObservedChargeFlow" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
128
+        <attribute name="targetBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
129
+        <attribute name="targetBatteryAlertTriggeredAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
130
+        <attribute name="requiresCompletionConfirmation" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
131
+        <attribute name="completionConfirmationRequestedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
132
+        <attribute name="completionContradictionPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
133
+        <attribute name="completionConfirmationCooldownUntil" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
134
+        <attribute name="trimStart" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
135
+        <attribute name="trimEnd" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
136
+        <attribute name="wasConflictHealed" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
137
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
138
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
139
+    </entity>
140
+    <entity name="ChargeCheckpoint" representedClassName="ChargeCheckpoint" syncable="YES" codeGenerationType="class">
141
+        <attribute name="id" optional="YES" attributeType="String"/>
142
+        <attribute name="sessionID" optional="YES" attributeType="String"/>
143
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
144
+        <attribute name="powerbankID" optional="YES" attributeType="String"/>
145
+        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
146
+        <attribute name="batteryPercent" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
147
+        <attribute name="batteryBarsValue" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
148
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
149
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
150
+        <attribute name="currentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
151
+        <attribute name="voltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
152
+        <attribute name="label" optional="YES" attributeType="String"/>
153
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
154
+    </entity>
155
+    <entity name="ChargeSessionSample" representedClassName="ChargeSessionSample" syncable="NO" codeGenerationType="class">
156
+        <attribute name="id" optional="YES" attributeType="String"/>
157
+        <attribute name="sessionID" optional="YES" attributeType="String"/>
158
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
159
+        <attribute name="bucketIndex" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
160
+        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
161
+        <attribute name="averageCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
162
+        <attribute name="averageVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
163
+        <attribute name="averagePowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
164
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
165
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
166
+        <attribute name="estimatedBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
167
+        <attribute name="sampleCount" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
168
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
169
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
170
+    </entity>
171
+    <elements>
172
+        <element name="ChargedDevice" positionX="-72" positionY="-36" width="128" height="463"/>
173
+        <element name="Powerbank" positionX="-72" positionY="450" width="128" height="328"/>
174
+        <element name="DeviceProfile" positionX="-288" positionY="-36" width="160" height="358"/>
175
+        <element name="ChargeSession" positionX="136" positionY="-36" width="128" height="913"/>
176
+        <element name="ChargeCheckpoint" positionX="344" positionY="-36" width="128" height="238"/>
177
+        <element name="ChargeSessionSample" positionX="552" positionY="-36" width="128" height="268"/>
178
+    </elements>
179
+</model>
+1007 -57
USB Meter/Model/ChargeInsightsModel.swift
@@ -537,14 +537,293 @@ struct ChargedDeviceTemplateCatalog {
537 537
     }
538 538
 }
539 539
 
540
+enum ProfileCategory: String, CaseIterable, Identifiable, Codable {
541
+    case phone
542
+    case tablet
543
+    case laptop
544
+    case watch
545
+    case audioAccessory
546
+    case accessoryCase
547
+    case charger
548
+    case powerbank
549
+    case other
550
+
551
+    var id: String { rawValue }
552
+
553
+    var title: String {
554
+        switch self {
555
+        case .phone: return "Phone"
556
+        case .tablet: return "Tablet"
557
+        case .laptop: return "Laptop"
558
+        case .watch: return "Watch"
559
+        case .audioAccessory: return "Audio Accessory"
560
+        case .accessoryCase: return "Charging Case"
561
+        case .charger: return "Charger"
562
+        case .powerbank: return "Powerbank"
563
+        case .other: return "Other"
564
+        }
565
+    }
566
+
567
+    var pluralTitle: String {
568
+        switch self {
569
+        case .phone: return "Phones"
570
+        case .tablet: return "Tablets"
571
+        case .laptop: return "Laptops"
572
+        case .watch: return "Watches"
573
+        case .audioAccessory: return "Audio Accessories"
574
+        case .accessoryCase: return "Charging Cases"
575
+        case .charger: return "Chargers"
576
+        case .powerbank: return "Powerbanks"
577
+        case .other: return "Other"
578
+        }
579
+    }
580
+
581
+    var symbolName: String {
582
+        switch self {
583
+        case .phone: return "iphone"
584
+        case .tablet: return "ipad"
585
+        case .laptop: return "laptopcomputer"
586
+        case .watch: return "applewatch"
587
+        case .audioAccessory: return "earbuds.case"
588
+        case .accessoryCase: return "airpods.case.fill"
589
+        case .charger: return "bolt.horizontal.circle"
590
+        case .powerbank: return "battery.100.bolt"
591
+        case .other: return "shippingbox"
592
+        }
593
+    }
594
+
595
+    var kind: ChargedDeviceKind {
596
+        self == .charger ? .charger : .device
597
+    }
598
+
599
+    static func fromLegacyDeviceClass(_ deviceClass: ChargedDeviceClass) -> ProfileCategory {
600
+        switch deviceClass {
601
+        case .iphone: return .phone
602
+        case .watch: return .watch
603
+        case .powerbank: return .powerbank
604
+        case .charger: return .charger
605
+        case .other: return .other
606
+        }
607
+    }
608
+}
609
+
610
+struct DeviceProfileDefinition: Identifiable, Hashable, Codable {
611
+    let id: String
612
+    let name: String
613
+    let group: String
614
+    let category: ProfileCategory
615
+    let icon: ChargedDeviceTemplateIcon
616
+    let sortOrder: Int
617
+
618
+    let capWiredCharging: Bool
619
+    let capWirelessCharging: Bool
620
+    let capWirelessProfiles: [WirelessChargingProfile]
621
+    let capChargingStateAvailability: ChargingStateAvailability
622
+    let capHasInternalSubject: Bool
623
+
624
+    let defaultWirelessChargingProfile: WirelessChargingProfile?
625
+    let defaultWiredMinimumCurrentAmps: Double?
626
+    let defaultWirelessMinimumCurrentAmps: Double?
627
+    let defaultWiredEstimatedBatteryCapacityWh: Double?
628
+    let defaultWirelessEstimatedBatteryCapacityWh: Double?
629
+
630
+    init(
631
+        id: String,
632
+        name: String,
633
+        group: String,
634
+        category: ProfileCategory,
635
+        icon: ChargedDeviceTemplateIcon,
636
+        sortOrder: Int,
637
+        capWiredCharging: Bool,
638
+        capWirelessCharging: Bool,
639
+        capWirelessProfiles: [WirelessChargingProfile],
640
+        capChargingStateAvailability: ChargingStateAvailability,
641
+        capHasInternalSubject: Bool,
642
+        defaultWirelessChargingProfile: WirelessChargingProfile? = nil,
643
+        defaultWiredMinimumCurrentAmps: Double? = nil,
644
+        defaultWirelessMinimumCurrentAmps: Double? = nil,
645
+        defaultWiredEstimatedBatteryCapacityWh: Double? = nil,
646
+        defaultWirelessEstimatedBatteryCapacityWh: Double? = nil
647
+    ) {
648
+        self.id = id
649
+        self.name = name
650
+        self.group = group
651
+        self.category = category
652
+        self.icon = icon
653
+        self.sortOrder = sortOrder
654
+        self.capWiredCharging = capWiredCharging
655
+        self.capWirelessCharging = capWirelessCharging
656
+        self.capWirelessProfiles = capWirelessProfiles
657
+        self.capChargingStateAvailability = capChargingStateAvailability
658
+        self.capHasInternalSubject = capHasInternalSubject
659
+        self.defaultWirelessChargingProfile = defaultWirelessChargingProfile
660
+        self.defaultWiredMinimumCurrentAmps = defaultWiredMinimumCurrentAmps
661
+        self.defaultWirelessMinimumCurrentAmps = defaultWirelessMinimumCurrentAmps
662
+        self.defaultWiredEstimatedBatteryCapacityWh = defaultWiredEstimatedBatteryCapacityWh
663
+        self.defaultWirelessEstimatedBatteryCapacityWh = defaultWirelessEstimatedBatteryCapacityWh
664
+    }
665
+
666
+    var capabilitySummary: String {
667
+        var components: [String] = [capChargingStateAvailability.title]
668
+        switch (capWiredCharging, capWirelessCharging) {
669
+        case (true, true): components.append("Wired + Wireless")
670
+        case (true, false): components.append("Wired only")
671
+        case (false, true): components.append("Wireless only")
672
+        case (false, false): components.append("No transport")
673
+        }
674
+        if capWirelessCharging, let primary = defaultWirelessChargingProfile {
675
+            components.append(primary.title)
676
+        }
677
+        return components.joined(separator: " • ")
678
+    }
679
+
680
+    var wirelessProfilesCSV: String {
681
+        capWirelessProfiles.map { $0.rawValue }.joined(separator: ",")
682
+    }
683
+
684
+    static func decodeWirelessProfilesCSV(_ csv: String?) -> [WirelessChargingProfile] {
685
+        guard let csv, !csv.isEmpty else { return [] }
686
+        return csv
687
+            .split(separator: ",")
688
+            .compactMap { WirelessChargingProfile(rawValue: String($0).trimmingCharacters(in: .whitespaces)) }
689
+    }
690
+}
691
+
692
+private struct DeviceProfileCatalogDocument: Codable {
693
+    let profiles: [DeviceProfileDefinition]
694
+}
695
+
696
+struct DeviceProfileCatalog {
697
+    static let shared = DeviceProfileCatalog()
698
+
699
+    let profiles: [DeviceProfileDefinition]
700
+    private let profilesByID: [String: DeviceProfileDefinition]
701
+
702
+    private init(bundle: Bundle = .main) {
703
+        let loaded: [DeviceProfileDefinition]
704
+
705
+        if let resourceURL = bundle.url(forResource: "DeviceProfilesCatalog", withExtension: "json"),
706
+           let data = try? Data(contentsOf: resourceURL),
707
+           let document = try? JSONDecoder().decode(DeviceProfileCatalogDocument.self, from: data) {
708
+            loaded = document.profiles
709
+        } else {
710
+            loaded = []
711
+        }
712
+
713
+        self.profiles = loaded.sorted { lhs, rhs in
714
+            if lhs.group != rhs.group {
715
+                return lhs.group < rhs.group
716
+            }
717
+            if lhs.sortOrder != rhs.sortOrder {
718
+                return lhs.sortOrder < rhs.sortOrder
719
+            }
720
+            return lhs.name < rhs.name
721
+        }
722
+        self.profilesByID = Dictionary(uniqueKeysWithValues: self.profiles.map { ($0.id, $0) })
723
+    }
724
+
725
+    func profile(id: String?) -> DeviceProfileDefinition? {
726
+        guard let id else { return nil }
727
+        return profilesByID[id]
728
+    }
729
+
730
+    func profiles(for category: ProfileCategory) -> [DeviceProfileDefinition] {
731
+        profiles.filter { $0.category == category }
732
+    }
733
+}
734
+
735
+/// Centralizes the autoexclusion rules that turn a `DeviceProfile` into a coherent
736
+/// device state. Called from the editor at edit time so impossible combinations are
737
+/// not even expressible — instead of being silently corrected at read time.
738
+enum DeviceProfileValidator {
739
+    struct AppliedState: Equatable {
740
+        var chargingStateAvailability: ChargingStateAvailability
741
+        var supportsWiredCharging: Bool
742
+        var supportsWirelessCharging: Bool
743
+        var wirelessChargingProfile: WirelessChargingProfile
744
+        var hasInternalSubject: Bool
745
+    }
746
+
747
+    /// Returns the canonical state for a freshly selected profile.
748
+    /// Used both when the user picks a profile in the editor and when seeding
749
+    /// new device defaults from a catalog entry.
750
+    static func canonicalState(for profile: DeviceProfileDefinition) -> AppliedState {
751
+        AppliedState(
752
+            chargingStateAvailability: profile.capChargingStateAvailability,
753
+            supportsWiredCharging: profile.capWiredCharging,
754
+            supportsWirelessCharging: profile.capWirelessCharging,
755
+            wirelessChargingProfile: profile.defaultWirelessChargingProfile
756
+                ?? profile.capWirelessProfiles.first
757
+                ?? .genericQi,
758
+            hasInternalSubject: false
759
+        )
760
+    }
761
+
762
+    /// Coerces a possibly-contradictory state to fit the profile's capabilities.
763
+    /// Preserves user-set values where they are still allowed; otherwise falls
764
+    /// back to canonical defaults.
765
+    static func coerce(
766
+        state: AppliedState,
767
+        to profile: DeviceProfileDefinition
768
+    ) -> AppliedState {
769
+        var coerced = state
770
+        coerced.supportsWiredCharging = state.supportsWiredCharging && profile.capWiredCharging
771
+        coerced.supportsWirelessCharging = state.supportsWirelessCharging && profile.capWirelessCharging
772
+        if !coerced.supportsWiredCharging && !coerced.supportsWirelessCharging {
773
+            coerced.supportsWiredCharging = profile.capWiredCharging
774
+            coerced.supportsWirelessCharging = profile.capWirelessCharging
775
+        }
776
+        coerced.chargingStateAvailability = profile.capChargingStateAvailability
777
+        if !profile.capWirelessProfiles.contains(state.wirelessChargingProfile) {
778
+            coerced.wirelessChargingProfile = profile.defaultWirelessChargingProfile
779
+                ?? profile.capWirelessProfiles.first
780
+                ?? .genericQi
781
+        }
782
+        if !profile.capHasInternalSubject {
783
+            coerced.hasInternalSubject = false
784
+        }
785
+        return coerced
786
+    }
787
+
788
+    /// True when the editor should offer the user a toggle for wired charging.
789
+    /// (False means the profile forbids wired entirely — hide the row.)
790
+    static func allowsWiredToggle(_ profile: DeviceProfileDefinition) -> Bool {
791
+        profile.capWiredCharging
792
+    }
793
+
794
+    static func allowsWirelessToggle(_ profile: DeviceProfileDefinition) -> Bool {
795
+        profile.capWirelessCharging
796
+    }
797
+
798
+    /// True when both transports are permitted — meaning the user may opt out of
799
+    /// either; otherwise the surviving transport is mandatory.
800
+    static func allowsTransportChoice(_ profile: DeviceProfileDefinition) -> Bool {
801
+        profile.capWiredCharging && profile.capWirelessCharging
802
+    }
803
+
804
+    /// True when there is more than one wireless profile to choose from for this
805
+    /// catalog entry. Shown as a picker; otherwise hidden (single value implied).
806
+    static func allowsWirelessProfileChoice(_ profile: DeviceProfileDefinition) -> Bool {
807
+        profile.capWirelessProfiles.count > 1
808
+    }
809
+
810
+    /// True when the profile's `capChargingStateAvailability` is fixed to a single
811
+    /// state mode — in which case the editor renders a locked badge instead of a picker.
812
+    static func chargingStateIsLocked(_ profile: DeviceProfileDefinition) -> Bool {
813
+        profile.capChargingStateAvailability == .onOnly
814
+            || profile.capChargingStateAvailability == .offOnly
815
+    }
816
+}
817
+
540 818
 struct ChargeCheckpointSummary: Identifiable, Hashable {
541 819
     let id: UUID
542 820
     let sessionID: UUID
543 821
     let chargedDeviceID: UUID
822
+    let powerbankID: UUID?
823
+    let batteryBarsValue: Int
544 824
     let timestamp: Date
545 825
     let batteryPercent: Double
546 826
     let measuredEnergyWh: Double
547
-    let measuredChargeAh: Double
548 827
     let currentAmps: Double
549 828
     let voltageVolts: Double?
550 829
     let label: String?
@@ -552,6 +831,10 @@ struct ChargeCheckpointSummary: Identifiable, Hashable {
552 831
     var flag: ChargeCheckpointFlag {
553 832
         ChargeCheckpointFlag.fromStoredLabel(label)
554 833
     }
834
+
835
+    var subject: CheckpointSubject {
836
+        powerbankID == nil ? .chargedDevice : .powerbank
837
+    }
555 838
 }
556 839
 
557 840
 enum ChargeCheckpointFlag: String, CaseIterable {
@@ -608,7 +891,7 @@ struct ChargeSessionSampleSummary: Identifiable, Hashable {
608 891
     let averageVoltageVolts: Double?
609 892
     let averagePowerWatts: Double
610 893
     let measuredEnergyWh: Double
611
-    let measuredChargeAh: Double
894
+    let estimatedBatteryPercent: Double?
612 895
     let sampleCount: Int
613 896
 
614 897
     var id: String {
@@ -619,7 +902,9 @@ struct ChargeSessionSampleSummary: Identifiable, Hashable {
619 902
 struct ChargeSessionSummary: Identifiable, Hashable {
620 903
     let id: UUID
621 904
     let chargedDeviceID: UUID
905
+    let chargedPowerbankID: UUID?
622 906
     let chargerID: UUID?
907
+    let sourcePowerbankID: UUID?
623 908
     let meterMACAddress: String?
624 909
     let meterName: String?
625 910
     let meterModel: String?
@@ -634,9 +919,7 @@ struct ChargeSessionSummary: Identifiable, Hashable {
634 919
     let autoStopEnabled: Bool
635 920
     let measuredEnergyWh: Double
636 921
     let effectiveBatteryEnergyWh: Double?
637
-    let measuredChargeAh: Double
638 922
     let meterEnergyBaselineWh: Double?
639
-    let meterChargeBaselineAh: Double?
640 923
     let meterDurationBaselineSeconds: Double?
641 924
     let meterLastDurationSeconds: Double?
642 925
     let minimumObservedCurrentAmps: Double?
@@ -688,6 +971,21 @@ struct ChargeSessionSummary: Identifiable, Hashable {
688 971
         )
689 972
     }
690 973
 
974
+    /// Generalized source slot. `none` when no source is tracked, `charger(id)` for the existing
975
+    /// charger flow, `powerbank(id)` when a powerbank is supplying power for this session.
976
+    var source: ChargeSessionSource {
977
+        if let sourcePowerbankID {
978
+            return .powerbank(sourcePowerbankID)
979
+        }
980
+        if let chargerID {
981
+            return .charger(chargerID)
982
+        }
983
+        return .none
984
+    }
985
+
986
+    var hasPowerbankSubject: Bool { chargedPowerbankID != nil }
987
+    var hasPowerbankSource: Bool { sourcePowerbankID != nil }
988
+
691 989
     var duration: TimeInterval {
692 990
         (endedAt ?? lastObservedAt).timeIntervalSince(startedAt)
693 991
     }
@@ -733,7 +1031,6 @@ struct ChargeSessionSummary: Identifiable, Hashable {
733 1031
     var hasSavableChargeData: Bool {
734 1032
         hasObservedChargeFlow
735 1033
             || measuredEnergyWh > 0
736
-            || measuredChargeAh > 0
737 1034
             || (maximumObservedCurrentAmps ?? 0) > 0
738 1035
             || (maximumObservedPowerWatts ?? 0) > 0
739 1036
             || !aggregatedSamples.isEmpty
@@ -745,6 +1042,13 @@ struct ChargeSessionSummary: Identifiable, Hashable {
745 1042
         return endBatteryPercent - startBatteryPercent
746 1043
     }
747 1044
 
1045
+    var startsFromFlatBattery: Bool {
1046
+        guard let startBatteryPercent else {
1047
+            return false
1048
+        }
1049
+        return startBatteryPercent.isFinite && startBatteryPercent < 0
1050
+    }
1051
+
748 1052
     var canAutoStop: Bool {
749 1053
         autoStopEnabled && stopThresholdAmps > 0
750 1054
     }
@@ -758,16 +1062,119 @@ struct ChargeSessionSummary: Identifiable, Hashable {
758 1062
     }
759 1063
 }
760 1064
 
1065
+enum BatteryLevelPredictionBasis: Hashable {
1066
+    case capacityEstimate
1067
+    case checkpointEnergyMap
1068
+    case typicalChargeCurve
1069
+
1070
+    var metricLabel: String {
1071
+        switch self {
1072
+        case .capacityEstimate:
1073
+            return "est. capacity"
1074
+        case .checkpointEnergyMap:
1075
+            return "energy map"
1076
+        case .typicalChargeCurve:
1077
+            return "charge curve"
1078
+        }
1079
+    }
1080
+
1081
+    var explanatoryLabel: String {
1082
+        switch self {
1083
+        case .capacityEstimate:
1084
+            return "estimated capacity"
1085
+        case .checkpointEnergyMap:
1086
+            return "checkpoint energy map"
1087
+        case .typicalChargeCurve:
1088
+            return "typical charge curve"
1089
+        }
1090
+    }
1091
+}
1092
+
761 1093
 struct BatteryLevelPrediction: Hashable {
762 1094
     let predictedPercent: Double
763
-    let estimatedCapacityWh: Double
1095
+    let estimatedCapacityWh: Double?
1096
+    let basis: BatteryLevelPredictionBasis
764 1097
     let anchorPercent: Double
765 1098
     let anchorEnergyWh: Double
766 1099
     let anchorDescription: String
1100
+
1101
+    func energyWh(forPercent percent: Double) -> Double? {
1102
+        guard let estimatedCapacityWh, estimatedCapacityWh > 0 else {
1103
+            return nil
1104
+        }
1105
+
1106
+        return anchorEnergyWh + ((percent - anchorPercent) / 100) * estimatedCapacityWh
1107
+    }
767 1108
 }
768 1109
 
769 1110
 enum BatteryLevelPredictionTuning {
770
-    static let checkpointSettleDuration: TimeInterval = 10 * 60
1111
+    static func inferredVirtualZeroEnergyWh(
1112
+        from anchors: [BatteryLevelPredictionAnchor],
1113
+        estimatedCapacityWh: Double? = nil,
1114
+        historicalReserveEnergyWh: Double? = nil
1115
+    ) -> Double? {
1116
+        let sortedAnchors = anchors
1117
+            .filter { $0.percent > 0 && $0.percent <= 100 && $0.energyWh >= 0 }
1118
+            .sorted { lhs, rhs in
1119
+                if lhs.energyWh != rhs.energyWh {
1120
+                    return lhs.energyWh < rhs.energyWh
1121
+                }
1122
+                return lhs.timestamp < rhs.timestamp
1123
+            }
1124
+
1125
+        guard let firstAnchor = sortedAnchors.first else {
1126
+            return nil
1127
+        }
1128
+
1129
+        func clampedReserve(_ reserveEnergyWh: Double) -> Double? {
1130
+            guard reserveEnergyWh.isFinite else {
1131
+                return nil
1132
+            }
1133
+            return min(max(reserveEnergyWh, 0), firstAnchor.energyWh)
1134
+        }
1135
+
1136
+        if let historicalReserveEnergyWh,
1137
+           let reserveEnergyWh = clampedReserve(historicalReserveEnergyWh) {
1138
+            return reserveEnergyWh
1139
+        }
1140
+
1141
+        if let estimatedCapacityWh,
1142
+           estimatedCapacityWh > 0 {
1143
+            return clampedReserve(
1144
+                firstAnchor.energyWh - ((firstAnchor.percent / 100) * estimatedCapacityWh)
1145
+            )
1146
+        }
1147
+
1148
+        var zeroCandidates: [Double] = []
1149
+
1150
+        for lowerIndex in sortedAnchors.indices {
1151
+            for upperIndex in sortedAnchors.indices where upperIndex > lowerIndex {
1152
+                let lower = sortedAnchors[lowerIndex]
1153
+                let upper = sortedAnchors[upperIndex]
1154
+                let percentDelta = upper.percent - lower.percent
1155
+                let energyDeltaWh = upper.energyWh - lower.energyWh
1156
+
1157
+                guard percentDelta >= 3, energyDeltaWh > 0.01 else {
1158
+                    continue
1159
+                }
1160
+
1161
+                let capacityWh = energyDeltaWh / (percentDelta / 100)
1162
+                guard capacityWh.isFinite, capacityWh > 0, capacityWh < 1_000 else {
1163
+                    continue
1164
+                }
1165
+
1166
+                zeroCandidates.append(lower.energyWh - ((lower.percent / 100) * capacityWh))
1167
+                zeroCandidates.append(upper.energyWh - ((upper.percent / 100) * capacityWh))
1168
+            }
1169
+        }
1170
+
1171
+        guard !zeroCandidates.isEmpty else {
1172
+            return nil
1173
+        }
1174
+
1175
+        let sortedCandidates = zeroCandidates.sorted()
1176
+        return clampedReserve(sortedCandidates[sortedCandidates.count / 2])
1177
+    }
771 1178
 
772 1179
     static func predictedPercent(
773 1180
         anchorPercent: Double,
@@ -778,26 +1185,92 @@ enum BatteryLevelPredictionTuning {
778 1185
         referenceTimestamp: Date,
779 1186
         estimatedCapacityWh: Double
780 1187
     ) -> Double {
1188
+        _ = anchorTimestamp
1189
+        _ = anchorIsCheckpoint
1190
+        _ = referenceTimestamp
1191
+
781 1192
         let energyDeltaWh = max(effectiveEnergyWh - anchorEnergyWh, 0)
782 1193
         let rawGainPercent = (energyDeltaWh / estimatedCapacityWh) * 100
783
-        let stabilizedGainPercent: Double
784
-
785
-        if anchorIsCheckpoint {
786
-            let elapsedSinceAnchor = max(referenceTimestamp.timeIntervalSince(anchorTimestamp), 0)
787
-            let settleProgress = min(max(elapsedSinceAnchor / checkpointSettleDuration, 0), 1)
788
-            stabilizedGainPercent = rawGainPercent * settleProgress
789
-        } else {
790
-            stabilizedGainPercent = rawGainPercent
791
-        }
792 1194
 
793 1195
         return min(
794 1196
             100,
795 1197
             max(
796 1198
                 0,
797
-                anchorPercent + stabilizedGainPercent
1199
+                anchorPercent + rawGainPercent
798 1200
             )
799 1201
         )
800 1202
     }
1203
+
1204
+    static func predictedPercent(
1205
+        anchorPercent: Double,
1206
+        anchorEnergyWh: Double,
1207
+        effectiveEnergyWh: Double,
1208
+        chargeCurve: BatteryChargeCurve,
1209
+        deviationFactor: Double?
1210
+    ) -> Double? {
1211
+        guard
1212
+            let curveAnchorEnergyWh = chargeCurve.energyWh(forPercent: anchorPercent)
1213
+        else {
1214
+            return nil
1215
+        }
1216
+
1217
+        let sessionEnergyDeltaWh = max(effectiveEnergyWh - anchorEnergyWh, 0)
1218
+        let normalizedEnergyDeltaWh = sessionEnergyDeltaWh / max(deviationFactor ?? 1, 0.05)
1219
+        let projectedCurveEnergyWh = curveAnchorEnergyWh + normalizedEnergyDeltaWh
1220
+
1221
+        guard let curvePercent = chargeCurve.percent(forEnergyWh: projectedCurveEnergyWh) else {
1222
+            return nil
1223
+        }
1224
+
1225
+        return min(100, max(anchorPercent, curvePercent))
1226
+    }
1227
+
1228
+    static func deviationFactor(
1229
+        anchors: [BatteryLevelPredictionAnchor],
1230
+        chargeCurve: BatteryChargeCurve
1231
+    ) -> Double? {
1232
+        let sortedAnchors = anchors.sorted { lhs, rhs in
1233
+            if lhs.timestamp != rhs.timestamp {
1234
+                return lhs.timestamp < rhs.timestamp
1235
+            }
1236
+            return lhs.energyWh < rhs.energyWh
1237
+        }
1238
+        var ratios: [Double] = []
1239
+
1240
+        for lowerIndex in sortedAnchors.indices {
1241
+            for upperIndex in sortedAnchors.indices where upperIndex > lowerIndex {
1242
+                let lower = sortedAnchors[lowerIndex]
1243
+                let upper = sortedAnchors[upperIndex]
1244
+                let percentDelta = upper.percent - lower.percent
1245
+                let energyDeltaWh = upper.energyWh - lower.energyWh
1246
+
1247
+                guard percentDelta >= 3, energyDeltaWh > 0.01,
1248
+                      let curveLowerEnergyWh = chargeCurve.energyWh(forPercent: lower.percent),
1249
+                      let curveUpperEnergyWh = chargeCurve.energyWh(forPercent: upper.percent) else {
1250
+                    continue
1251
+                }
1252
+
1253
+                let curveEnergyDeltaWh = curveUpperEnergyWh - curveLowerEnergyWh
1254
+                guard curveEnergyDeltaWh > 0.01 else {
1255
+                    continue
1256
+                }
1257
+
1258
+                let ratio = energyDeltaWh / curveEnergyDeltaWh
1259
+                guard ratio.isFinite, ratio > 0 else {
1260
+                    continue
1261
+                }
1262
+
1263
+                ratios.append(min(max(ratio, 0.25), 4.0))
1264
+            }
1265
+        }
1266
+
1267
+        guard !ratios.isEmpty else {
1268
+            return nil
1269
+        }
1270
+
1271
+        let sortedRatios = ratios.sorted()
1272
+        return sortedRatios[sortedRatios.count / 2]
1273
+    }
801 1274
 }
802 1275
 
803 1276
 struct CapacityTrendPoint: Identifiable, Hashable {
@@ -812,12 +1285,122 @@ struct CapacityTrendPoint: Identifiable, Hashable {
812 1285
 struct TypicalChargeCurvePoint: Identifiable, Hashable {
813 1286
     let percentBin: Int
814 1287
     let averageEnergyWh: Double
815
-    let averageChargeAh: Double
816 1288
     let sampleCount: Int
817 1289
 
818 1290
     var id: Int { percentBin }
819 1291
 }
820 1292
 
1293
+struct BatteryLevelPredictionAnchor: Hashable {
1294
+    let percent: Double
1295
+    let energyWh: Double
1296
+    let timestamp: Date
1297
+    let description: String
1298
+    let isCheckpoint: Bool
1299
+
1300
+    init(
1301
+        percent: Double,
1302
+        energyWh: Double,
1303
+        timestamp: Date,
1304
+        description: String = "",
1305
+        isCheckpoint: Bool
1306
+    ) {
1307
+        self.percent = percent
1308
+        self.energyWh = energyWh
1309
+        self.timestamp = timestamp
1310
+        self.description = description
1311
+        self.isCheckpoint = isCheckpoint
1312
+    }
1313
+}
1314
+
1315
+struct BatteryChargeCurve {
1316
+    private let points: [(percent: Double, energyWh: Double)]
1317
+
1318
+    init?(typicalCurvePoints: [TypicalChargeCurvePoint]) {
1319
+        let validPoints = typicalCurvePoints
1320
+            .filter {
1321
+                $0.averageEnergyWh.isFinite
1322
+                    && $0.averageEnergyWh >= 0
1323
+                    && $0.percentBin >= 0
1324
+                    && $0.percentBin <= 100
1325
+            }
1326
+            .sorted { lhs, rhs in
1327
+                lhs.percentBin < rhs.percentBin
1328
+            }
1329
+
1330
+        var normalizedPoints: [(percent: Double, energyWh: Double)] = []
1331
+        var runningMaximumEnergyWh = 0.0
1332
+
1333
+        for point in validPoints {
1334
+            runningMaximumEnergyWh = max(runningMaximumEnergyWh, point.averageEnergyWh)
1335
+            normalizedPoints.append(
1336
+                (percent: Double(point.percentBin), energyWh: runningMaximumEnergyWh)
1337
+            )
1338
+        }
1339
+
1340
+        guard normalizedPoints.count >= 2 else {
1341
+            return nil
1342
+        }
1343
+
1344
+        self.points = normalizedPoints
1345
+    }
1346
+
1347
+    func energyWh(forPercent percent: Double) -> Double? {
1348
+        interpolatedValue(
1349
+            lookup: min(max(percent, 0), 100),
1350
+            key: { $0.percent },
1351
+            value: { $0.energyWh }
1352
+        )
1353
+    }
1354
+
1355
+    func percent(forEnergyWh energyWh: Double) -> Double? {
1356
+        interpolatedValue(
1357
+            lookup: max(energyWh, 0),
1358
+            key: { $0.energyWh },
1359
+            value: { $0.percent }
1360
+        )
1361
+    }
1362
+
1363
+    private func interpolatedValue(
1364
+        lookup: Double,
1365
+        key: ((percent: Double, energyWh: Double)) -> Double,
1366
+        value: ((percent: Double, energyWh: Double)) -> Double
1367
+    ) -> Double? {
1368
+        guard let first = points.first, let last = points.last else {
1369
+            return nil
1370
+        }
1371
+
1372
+        let firstKey = key(first)
1373
+        let lastKey = key(last)
1374
+        guard lookup >= firstKey, lookup <= lastKey else {
1375
+            return nil
1376
+        }
1377
+
1378
+        if abs(lookup - firstKey) < 0.000_1 {
1379
+            return value(first)
1380
+        }
1381
+        if abs(lookup - lastKey) < 0.000_1 {
1382
+            return value(last)
1383
+        }
1384
+
1385
+        guard let upperIndex = points.firstIndex(where: { key($0) >= lookup }),
1386
+              upperIndex > 0 else {
1387
+            return nil
1388
+        }
1389
+
1390
+        let lower = points[upperIndex - 1]
1391
+        let upper = points[upperIndex]
1392
+        let lowerKey = key(lower)
1393
+        let upperKey = key(upper)
1394
+        let span = upperKey - lowerKey
1395
+        guard span > 0.000_1 else {
1396
+            return value(upper)
1397
+        }
1398
+
1399
+        let progress = (lookup - lowerKey) / span
1400
+        return value(lower) + ((value(upper) - value(lower)) * progress)
1401
+    }
1402
+}
1403
+
821 1404
 struct ChargerStandbyPowerSample: Identifiable, Hashable, Codable {
822 1405
     let timestamp: Date
823 1406
     let powerWatts: Double
@@ -1246,6 +1829,55 @@ enum ChargerStandbyPowerMeasurementAnalyzer {
1246 1829
     }
1247 1830
 }
1248 1831
 
1832
+// MARK: - Consumption Monitor
1833
+
1834
+struct ConsumptionMonitorSample: Identifiable, Codable, Hashable {
1835
+    var id: Int { bucketIndex }
1836
+    let bucketIndex: Int
1837
+    let timestamp: Date
1838
+    let averagePowerWatts: Double
1839
+    let averageCurrentAmps: Double
1840
+    let averageVoltageVolts: Double
1841
+    let sampleCount: Int
1842
+    let cumulativeEnergyWh: Double
1843
+}
1844
+
1845
+struct ConsumptionMonitorSessionSummary: Identifiable, Codable, Hashable {
1846
+    let id: UUID
1847
+    let chargedDeviceID: UUID
1848
+    let meterMACAddress: String
1849
+    let meterName: String?
1850
+    let meterModel: String?
1851
+    let startedAt: Date
1852
+    var endedAt: Date?
1853
+    var samples: [ConsumptionMonitorSample]
1854
+
1855
+    var isOpen: Bool { endedAt == nil }
1856
+    var duration: TimeInterval { (endedAt ?? Date()).timeIntervalSince(startedAt) }
1857
+    var totalEnergyWh: Double { samples.last?.cumulativeEnergyWh ?? 0 }
1858
+    var sampleCount: Int { samples.count }
1859
+
1860
+    var averagePowerWatts: Double {
1861
+        guard !samples.isEmpty else { return 0 }
1862
+        return samples.map(\.averagePowerWatts).reduce(0, +) / Double(samples.count)
1863
+    }
1864
+    var minimumPowerWatts: Double { samples.map(\.averagePowerWatts).min() ?? 0 }
1865
+    var maximumPowerWatts: Double { samples.map(\.averagePowerWatts).max() ?? 0 }
1866
+    var averageCurrentAmps: Double {
1867
+        guard !samples.isEmpty else { return 0 }
1868
+        return samples.map(\.averageCurrentAmps).reduce(0, +) / Double(samples.count)
1869
+    }
1870
+    var averageVoltageVolts: Double {
1871
+        guard !samples.isEmpty else { return 0 }
1872
+        return samples.map(\.averageVoltageVolts).reduce(0, +) / Double(samples.count)
1873
+    }
1874
+
1875
+    var projectedDailyEnergyWh: Double { averagePowerWatts * 24 }
1876
+    var projectedWeeklyEnergyWh: Double { averagePowerWatts * 24 * 7 }
1877
+    var projectedMonthlyEnergyWh: Double { averagePowerWatts * 24 * 30 }
1878
+    var projectedYearlyEnergyWh: Double { averagePowerWatts * 24 * 365 }
1879
+}
1880
+
1249 1881
 struct ChargedDeviceSummary: Identifiable, Hashable {
1250 1882
     let id: UUID
1251 1883
     let qrIdentifier: String
@@ -1253,6 +1885,8 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
1253 1885
     let deviceClass: ChargedDeviceClass
1254 1886
     let deviceTemplateID: String?
1255 1887
     let templateDefinition: ChargedDeviceTemplateDefinition?
1888
+    let profileID: String?
1889
+    let hasInternalSubject: Bool
1256 1890
     let supportsChargingWhileOff: Bool
1257 1891
     let chargingStateAvailability: ChargingStateAvailability
1258 1892
     let supportsWiredCharging: Bool
@@ -1275,18 +1909,29 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
1275 1909
     let wirelessMinimumCurrentAmps: Double?
1276 1910
     let wiredEstimatedBatteryCapacityWh: Double?
1277 1911
     let wirelessEstimatedBatteryCapacityWh: Double?
1278
-    let lastAssociatedMeterMAC: String?
1279 1912
     let createdAt: Date
1280 1913
     let updatedAt: Date
1281 1914
     let sessions: [ChargeSessionSummary]
1282 1915
     let capacityHistory: [CapacityTrendPoint]
1283 1916
     let typicalCurve: [TypicalChargeCurvePoint]
1284 1917
     let standbyPowerMeasurements: [ChargerStandbyPowerMeasurementSummary]
1918
+    let consumptionSessions: [ConsumptionMonitorSessionSummary]
1285 1919
 
1286 1920
     var isCharger: Bool {
1287 1921
         deviceClass == .charger
1288 1922
     }
1289 1923
 
1924
+    /// True when the device's active catalog profile is one of the case-style
1925
+    /// profiles (AirPods case, charging case, …) — i.e. the editor exposes the
1926
+    /// `hasInternalSubject` toggle and the detail UI should surface its state.
1927
+    var supportsInternalSubject: Bool {
1928
+        guard let profileID,
1929
+              let profile = DeviceProfileCatalog.shared.profile(id: profileID) else {
1930
+            return false
1931
+        }
1932
+        return profile.capHasInternalSubject
1933
+    }
1934
+
1290 1935
     var kind: ChargedDeviceKind {
1291 1936
         deviceClass.kind
1292 1937
     }
@@ -1455,33 +2100,58 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
1455 2100
 
1456 2101
     func batteryLevelPrediction(
1457 2102
         for session: ChargeSessionSummary,
1458
-        effectiveEnergyWhOverride: Double? = nil
2103
+        effectiveEnergyWhOverride: Double? = nil,
2104
+        referenceTimestamp: Date? = nil
1459 2105
     ) -> BatteryLevelPrediction? {
1460 2106
         let estimatedCapacityWh = session.capacityEstimateWh
1461 2107
             ?? estimatedBatteryCapacityWh(for: session.chargingTransportMode)
1462 2108
             ?? estimatedBatteryCapacityWh
1463 2109
 
1464
-        guard let estimatedCapacityWh, estimatedCapacityWh > 0 else {
1465
-            return nil
1466
-        }
1467
-
1468 2110
         let effectiveEnergyWh = effectiveEnergyWhOverride
1469 2111
             ?? session.effectiveBatteryEnergyWh
1470 2112
             ?? session.measuredEnergyWh
1471 2113
 
1472
-        struct Anchor {
1473
-            let percent: Double
1474
-            let energyWh: Double
1475
-            let timestamp: Date
1476
-            let description: String
1477
-            let isCheckpoint: Bool
2114
+        func anchorCapacityCandidates(from anchors: [BatteryLevelPredictionAnchor]) -> [Double] {
2115
+            var candidates: [Double] = []
2116
+
2117
+            for lowerIndex in anchors.indices {
2118
+                for upperIndex in anchors.indices where upperIndex > lowerIndex {
2119
+                    let lower = anchors[lowerIndex]
2120
+                    let upper = anchors[upperIndex]
2121
+                    let percentDelta = upper.percent - lower.percent
2122
+                    let energyDelta = upper.energyWh - lower.energyWh
2123
+
2124
+                    guard percentDelta >= 3, energyDelta > 0.01 else {
2125
+                        continue
2126
+                    }
2127
+
2128
+                    let capacityWh = energyDelta / (percentDelta / 100)
2129
+                    guard capacityWh.isFinite, capacityWh > 0, capacityWh < 1_000 else {
2130
+                        continue
2131
+                    }
2132
+
2133
+                    candidates.append(capacityWh)
2134
+                }
2135
+            }
2136
+
2137
+            return candidates
1478 2138
         }
1479 2139
 
1480
-        var anchors: [Anchor] = []
2140
+        func inferredCheckpointEnergyMapCapacityWh(from anchors: [BatteryLevelPredictionAnchor]) -> Double? {
2141
+            let candidates = anchorCapacityCandidates(from: anchors)
2142
+            guard !candidates.isEmpty else {
2143
+                return nil
2144
+            }
2145
+
2146
+            let sortedCandidates = candidates.sorted()
2147
+            return sortedCandidates[sortedCandidates.count / 2]
2148
+        }
2149
+
2150
+        var anchors: [BatteryLevelPredictionAnchor] = []
1481 2151
 
1482 2152
         if let startBatteryPercent = session.startBatteryPercent, startBatteryPercent >= 0 {
1483 2153
             anchors.append(
1484
-                Anchor(
2154
+                BatteryLevelPredictionAnchor(
1485 2155
                     percent: startBatteryPercent,
1486 2156
                     energyWh: 0,
1487 2157
                     timestamp: session.effectiveTrimStart,
@@ -1493,17 +2163,9 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
1493 2163
 
1494 2164
         anchors.append(
1495 2165
             contentsOf: session.checkpoints
1496
-                .sorted { lhs, rhs in
1497
-                    if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
1498
-                        return lhs.measuredEnergyWh < rhs.measuredEnergyWh
1499
-                    }
1500
-                    return lhs.timestamp < rhs.timestamp
1501
-                }
1502
-                .filter { checkpoint in
1503
-                    checkpoint.batteryPercent >= 0
1504
-                }
2166
+                .filter { $0.batteryPercent >= 0 }
1505 2167
                 .map { checkpoint in
1506
-                    return Anchor(
2168
+                    BatteryLevelPredictionAnchor(
1507 2169
                         percent: checkpoint.batteryPercent,
1508 2170
                         energyWh: checkpoint.measuredEnergyWh,
1509 2171
                         timestamp: checkpoint.timestamp,
@@ -1513,31 +2175,152 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
1513 2175
                 }
1514 2176
         )
1515 2177
 
1516
-        guard !anchors.isEmpty else {
2178
+        if session.startsFromFlatBattery {
2179
+            if let virtualZeroEnergyWh = BatteryLevelPredictionTuning.inferredVirtualZeroEnergyWh(
2180
+                from: anchors,
2181
+                estimatedCapacityWh: estimatedCapacityWh,
2182
+                historicalReserveEnergyWh: estimatedFlatReserveEnergyWh(excluding: session.id)
2183
+            ) {
2184
+                anchors.append(
2185
+                    BatteryLevelPredictionAnchor(
2186
+                        percent: 0,
2187
+                        energyWh: virtualZeroEnergyWh,
2188
+                        timestamp: session.effectiveTrimStart,
2189
+                        description: "estimated flat reserve",
2190
+                        isCheckpoint: false
2191
+                    )
2192
+                )
2193
+            } else if let firstCheckpoint = anchors.min(by: { $0.energyWh < $1.energyWh }),
2194
+                      effectiveEnergyWh < firstCheckpoint.energyWh - 0.05 {
2195
+                return nil
2196
+            }
2197
+        }
2198
+
2199
+        let sortedAnchors = anchors.sorted { lhs, rhs in
2200
+            if lhs.energyWh != rhs.energyWh {
2201
+                return lhs.energyWh < rhs.energyWh
2202
+            }
2203
+            return lhs.timestamp < rhs.timestamp
2204
+        }
2205
+
2206
+        guard !sortedAnchors.isEmpty else {
1517 2207
             return nil
1518 2208
         }
1519 2209
 
1520
-        let eligibleAnchors = anchors.filter { $0.energyWh <= effectiveEnergyWh + 0.05 }
1521
-        let anchor = eligibleAnchors.last ?? anchors.first!
1522
-        let predictedPercent = BatteryLevelPredictionTuning.predictedPercent(
1523
-            anchorPercent: anchor.percent,
1524
-            anchorEnergyWh: anchor.energyWh,
1525
-            anchorTimestamp: anchor.timestamp,
1526
-            anchorIsCheckpoint: anchor.isCheckpoint,
1527
-            effectiveEnergyWh: effectiveEnergyWh,
1528
-            referenceTimestamp: session.lastObservedAt,
1529
-            estimatedCapacityWh: estimatedCapacityWh
1530
-        )
2210
+        let canUseCheckpointEnergyMap = deviceClass == .watch || deviceClass == .iphone
2211
+        let inferredCapacityWh = estimatedCapacityWh
2212
+            ?? (canUseCheckpointEnergyMap ? inferredCheckpointEnergyMapCapacityWh(from: sortedAnchors) : nil)
2213
+        let fallbackBasis: BatteryLevelPredictionBasis = estimatedCapacityWh == nil
2214
+            ? .checkpointEnergyMap
2215
+            : .capacityEstimate
2216
+
2217
+        let lowerAnchor = sortedAnchors.last { $0.energyWh <= effectiveEnergyWh + 0.05 }
2218
+        let upperAnchor = sortedAnchors.first { $0.energyWh > effectiveEnergyWh + 0.05 }
2219
+        let anchor = lowerAnchor ?? upperAnchor ?? sortedAnchors.first!
2220
+
2221
+        let predictedPercent: Double
2222
+        let basis: BatteryLevelPredictionBasis
2223
+        if let lowerAnchor,
2224
+           let upperAnchor,
2225
+           upperAnchor.energyWh > lowerAnchor.energyWh + 0.05 {
2226
+            let interpolationProgress = min(
2227
+                max(
2228
+                    (effectiveEnergyWh - lowerAnchor.energyWh) /
2229
+                    (upperAnchor.energyWh - lowerAnchor.energyWh),
2230
+                    0
2231
+                ),
2232
+                1
2233
+            )
2234
+            predictedPercent = min(
2235
+                max(
2236
+                    lowerAnchor.percent +
2237
+                    (upperAnchor.percent - lowerAnchor.percent) * interpolationProgress,
2238
+                    0
2239
+                ),
2240
+                100
2241
+            )
2242
+            basis = fallbackBasis
2243
+        } else {
2244
+            let chargeCurve = BatteryChargeCurve(typicalCurvePoints: typicalCurve)
2245
+            let curveDeviationFactor = chargeCurve.flatMap {
2246
+                BatteryLevelPredictionTuning.deviationFactor(
2247
+                    anchors: sortedAnchors,
2248
+                    chargeCurve: $0
2249
+                )
2250
+            }
2251
+            let curvePredictedPercent = chargeCurve.flatMap {
2252
+                BatteryLevelPredictionTuning.predictedPercent(
2253
+                    anchorPercent: anchor.percent,
2254
+                    anchorEnergyWh: anchor.energyWh,
2255
+                    effectiveEnergyWh: effectiveEnergyWh,
2256
+                    chargeCurve: $0,
2257
+                    deviationFactor: curveDeviationFactor
2258
+                )
2259
+            }
2260
+
2261
+            if let curvePredictedPercent {
2262
+                predictedPercent = curvePredictedPercent
2263
+                basis = .typicalChargeCurve
2264
+            } else {
2265
+                guard let inferredCapacityWh, inferredCapacityWh > 0 else {
2266
+                    return nil
2267
+                }
2268
+
2269
+                predictedPercent = BatteryLevelPredictionTuning.predictedPercent(
2270
+                    anchorPercent: anchor.percent,
2271
+                    anchorEnergyWh: anchor.energyWh,
2272
+                    anchorTimestamp: anchor.timestamp,
2273
+                    anchorIsCheckpoint: anchor.isCheckpoint,
2274
+                    effectiveEnergyWh: effectiveEnergyWh,
2275
+                    referenceTimestamp: referenceTimestamp ?? session.lastObservedAt,
2276
+                    estimatedCapacityWh: inferredCapacityWh
2277
+                )
2278
+                basis = fallbackBasis
2279
+            }
2280
+        }
1531 2281
 
1532 2282
         return BatteryLevelPrediction(
1533 2283
             predictedPercent: predictedPercent,
1534
-            estimatedCapacityWh: estimatedCapacityWh,
2284
+            estimatedCapacityWh: inferredCapacityWh,
2285
+            basis: basis,
1535 2286
             anchorPercent: anchor.percent,
1536 2287
             anchorEnergyWh: anchor.energyWh,
1537 2288
             anchorDescription: anchor.description
1538 2289
         )
1539 2290
     }
1540 2291
 
2292
+    private func estimatedFlatReserveEnergyWh(excluding excludedSessionID: UUID? = nil) -> Double? {
2293
+        let reserves = sessions.compactMap { session -> Double? in
2294
+            guard session.id != excludedSessionID,
2295
+                  session.status == .completed,
2296
+                  session.startsFromFlatBattery else {
2297
+                return nil
2298
+            }
2299
+
2300
+            let anchors = session.checkpoints.map {
2301
+                BatteryLevelPredictionAnchor(
2302
+                    percent: $0.batteryPercent,
2303
+                    energyWh: $0.measuredEnergyWh,
2304
+                    timestamp: $0.timestamp,
2305
+                    description: $0.flag.anchorDescription,
2306
+                    isCheckpoint: true
2307
+                )
2308
+            }
2309
+
2310
+            return BatteryLevelPredictionTuning.inferredVirtualZeroEnergyWh(
2311
+                from: anchors,
2312
+                estimatedCapacityWh: session.capacityEstimateWh
2313
+            )
2314
+        }
2315
+
2316
+        guard !reserves.isEmpty else {
2317
+            return nil
2318
+        }
2319
+
2320
+        let sortedReserves = reserves.sorted()
2321
+        return sortedReserves[sortedReserves.count / 2]
2322
+    }
2323
+
1541 2324
     func withStandbyPowerMeasurements(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> ChargedDeviceSummary {
1542 2325
         ChargedDeviceSummary(
1543 2326
             id: id,
@@ -1546,6 +2329,8 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
1546 2329
             deviceClass: deviceClass,
1547 2330
             deviceTemplateID: deviceTemplateID,
1548 2331
             templateDefinition: templateDefinition,
2332
+            profileID: profileID,
2333
+            hasInternalSubject: hasInternalSubject,
1549 2334
             supportsChargingWhileOff: supportsChargingWhileOff,
1550 2335
             chargingStateAvailability: chargingStateAvailability,
1551 2336
             supportsWiredCharging: supportsWiredCharging,
@@ -1568,13 +2353,55 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
1568 2353
             wirelessMinimumCurrentAmps: wirelessMinimumCurrentAmps,
1569 2354
             wiredEstimatedBatteryCapacityWh: wiredEstimatedBatteryCapacityWh,
1570 2355
             wirelessEstimatedBatteryCapacityWh: wirelessEstimatedBatteryCapacityWh,
1571
-            lastAssociatedMeterMAC: lastAssociatedMeterMAC,
1572 2356
             createdAt: createdAt,
1573 2357
             updatedAt: updatedAt,
1574 2358
             sessions: sessions,
1575 2359
             capacityHistory: capacityHistory,
1576 2360
             typicalCurve: typicalCurve,
1577
-            standbyPowerMeasurements: measurements
2361
+            standbyPowerMeasurements: measurements,
2362
+            consumptionSessions: consumptionSessions
2363
+        )
2364
+    }
2365
+
2366
+    func withConsumptionSessions(_ sessions: [ConsumptionMonitorSessionSummary]) -> ChargedDeviceSummary {
2367
+        ChargedDeviceSummary(
2368
+            id: id,
2369
+            qrIdentifier: qrIdentifier,
2370
+            name: name,
2371
+            deviceClass: deviceClass,
2372
+            deviceTemplateID: deviceTemplateID,
2373
+            templateDefinition: templateDefinition,
2374
+            profileID: profileID,
2375
+            hasInternalSubject: hasInternalSubject,
2376
+            supportsChargingWhileOff: supportsChargingWhileOff,
2377
+            chargingStateAvailability: chargingStateAvailability,
2378
+            supportsWiredCharging: supportsWiredCharging,
2379
+            supportsWirelessCharging: supportsWirelessCharging,
2380
+            chargerType: chargerType,
2381
+            wirelessChargingProfile: wirelessChargingProfile,
2382
+            configuredCompletionCurrents: configuredCompletionCurrents,
2383
+            learnedCompletionCurrents: learnedCompletionCurrents,
2384
+            wirelessChargerEfficiencyFactor: wirelessChargerEfficiencyFactor,
2385
+            wiredChargeCompletionCurrentAmps: wiredChargeCompletionCurrentAmps,
2386
+            wirelessChargeCompletionCurrentAmps: wirelessChargeCompletionCurrentAmps,
2387
+            chargerObservedVoltageSelections: chargerObservedVoltageSelections,
2388
+            chargerIdleCurrentAmps: chargerIdleCurrentAmps,
2389
+            chargerEfficiencyFactor: chargerEfficiencyFactor,
2390
+            chargerMaximumPowerWatts: chargerMaximumPowerWatts,
2391
+            notes: notes,
2392
+            minimumCurrentAmps: minimumCurrentAmps,
2393
+            estimatedBatteryCapacityWh: estimatedBatteryCapacityWh,
2394
+            wiredMinimumCurrentAmps: wiredMinimumCurrentAmps,
2395
+            wirelessMinimumCurrentAmps: wirelessMinimumCurrentAmps,
2396
+            wiredEstimatedBatteryCapacityWh: wiredEstimatedBatteryCapacityWh,
2397
+            wirelessEstimatedBatteryCapacityWh: wirelessEstimatedBatteryCapacityWh,
2398
+            createdAt: createdAt,
2399
+            updatedAt: updatedAt,
2400
+            sessions: self.sessions,
2401
+            capacityHistory: capacityHistory,
2402
+            typicalCurve: typicalCurve,
2403
+            standbyPowerMeasurements: standbyPowerMeasurements,
2404
+            consumptionSessions: sessions
1578 2405
         )
1579 2406
     }
1580 2407
 }
@@ -1593,3 +2420,126 @@ struct ChargingMonitorSnapshot {
1593 2420
     let meterRecordingDurationSeconds: TimeInterval?
1594 2421
     let fallbackStopThresholdAmps: Double
1595 2422
 }
2423
+
2424
+// MARK: - Powerbank
2425
+
2426
+enum BatteryLevelReporting: String, CaseIterable, Identifiable, Codable {
2427
+    case percent
2428
+    case bars
2429
+    case fullOnly
2430
+    case none
2431
+
2432
+    var id: String { rawValue }
2433
+
2434
+    var title: String {
2435
+        switch self {
2436
+        case .percent: return "Percent"
2437
+        case .bars: return "Bars"
2438
+        case .fullOnly: return "Full only"
2439
+        case .none: return "Not reported"
2440
+        }
2441
+    }
2442
+
2443
+    var description: String {
2444
+        switch self {
2445
+        case .percent:
2446
+            return "The powerbank reports battery level as 0–100%."
2447
+        case .bars:
2448
+            return "The powerbank reports battery level as discrete bars (e.g. 4 of 4)."
2449
+        case .fullOnly:
2450
+            return "The powerbank has a single LED that lights only when charging completes — there is no signal for any partial level."
2451
+        case .none:
2452
+            return "The powerbank does not report a battery level."
2453
+        }
2454
+    }
2455
+
2456
+    var allowsCheckpoints: Bool {
2457
+        self != .none
2458
+    }
2459
+}
2460
+
2461
+enum CheckpointSubject: String, Codable, Hashable {
2462
+    case chargedDevice
2463
+    case powerbank
2464
+
2465
+    var title: String {
2466
+        switch self {
2467
+        case .chargedDevice: return "Device"
2468
+        case .powerbank: return "Powerbank"
2469
+        }
2470
+    }
2471
+}
2472
+
2473
+enum ChargeSessionSource: Hashable {
2474
+    case none
2475
+    case charger(UUID)
2476
+    case powerbank(UUID)
2477
+
2478
+    var chargerID: UUID? {
2479
+        if case .charger(let id) = self { return id }
2480
+        return nil
2481
+    }
2482
+
2483
+    var powerbankID: UUID? {
2484
+        if case .powerbank(let id) = self { return id }
2485
+        return nil
2486
+    }
2487
+
2488
+    var isTracked: Bool {
2489
+        if case .none = self { return false }
2490
+        return true
2491
+    }
2492
+}
2493
+
2494
+struct PowerbankSummary: Identifiable, Hashable {
2495
+    let id: UUID
2496
+    let qrIdentifier: String
2497
+    let name: String
2498
+    let deviceTemplateID: String?
2499
+    let templateDefinition: ChargedDeviceTemplateDefinition?
2500
+    let batteryLevelReporting: BatteryLevelReporting
2501
+    let batteryBarsCount: Int
2502
+    let estimatedBatteryCapacityWh: Double?
2503
+    let apparentCapacityWh: Double?
2504
+    let configuredCompletionCurrentAmps: Double?
2505
+    let learnedCompletionCurrentAmps: Double?
2506
+    let minimumCurrentAmps: Double?
2507
+    let sourceObservedVoltageSelections: [Double]
2508
+    let sourceVoltageMaxCurrents: [Double: Double]
2509
+    let sourceIdleCurrentAmps: Double?
2510
+    let sourceMaximumPowerWatts: Double?
2511
+    let sourceEfficiencyFactor: Double?
2512
+    let notes: String?
2513
+    let createdAt: Date
2514
+    let updatedAt: Date
2515
+    let sessionsAsSubject: [ChargeSessionSummary]
2516
+    let sessionsAsSource: [ChargeSessionSummary]
2517
+
2518
+    var fallbackIdentitySymbolName: String { "battery.100.bolt" }
2519
+
2520
+    var identityIcon: ChargedDeviceTemplateIcon {
2521
+        templateDefinition?.icon ?? .systemSymbol(fallbackIdentitySymbolName)
2522
+    }
2523
+
2524
+    var identitySymbolName: String {
2525
+        identityIcon.resolvedSystemSymbolName(fallbackSystemName: fallbackIdentitySymbolName)
2526
+    }
2527
+
2528
+    var identityTitle: String {
2529
+        templateDefinition?.name ?? "Powerbank"
2530
+    }
2531
+
2532
+    /// Open session in which this powerbank participates as either subject or source.
2533
+    var openSession: ChargeSessionSummary? {
2534
+        sessionsAsSubject.first(where: \.isOpen)
2535
+            ?? sessionsAsSource.first(where: \.isOpen)
2536
+    }
2537
+
2538
+    var totalDeliveredEnergyWh: Double {
2539
+        sessionsAsSource.reduce(0) { $0 + $1.measuredEnergyWh }
2540
+    }
2541
+
2542
+    var totalReceivedEnergyWh: Double {
2543
+        sessionsAsSubject.reduce(0) { $0 + $1.measuredEnergyWh }
2544
+    }
2545
+}
+1310 -255
USB Meter/Model/ChargeInsightsStore.swift
@@ -11,27 +11,15 @@ import Foundation
11 11
 final class ChargeInsightsStore {
12 12
     private enum EntityName {
13 13
         static let chargedDevice = "ChargedDevice"
14
+        static let powerbank = "Powerbank"
14 15
         static let chargeSession = "ChargeSession"
15 16
         static let chargeCheckpoint = "ChargeCheckpoint"
16 17
         static let chargeSessionSample = "ChargeSessionSample"
17
-    }
18
-
19
-    private enum MeterAssignmentKind {
20
-        case chargedDevice
21
-        case charger
22
-
23
-        var expectsChargerClass: Bool {
24
-            switch self {
25
-            case .chargedDevice:
26
-                return false
27
-            case .charger:
28
-                return true
29
-            }
30
-        }
18
+        static let deviceProfile = "DeviceProfile"
31 19
     }
32 20
 
33 21
     private static let maximumChargeSessionDuration: TimeInterval = 12 * 60 * 60
34
-    private static let persistedSamplesPerHour = 360
22
+    private static let persistedSamplesPerHour = 300
35 23
     private static let aggregatedSampleBucketDuration = 3600.0 / Double(persistedSamplesPerHour)
36 24
 
37 25
     private let context: NSManagedObjectContext
@@ -65,6 +53,223 @@ final class ChargeInsightsStore {
65 53
         }
66 54
     }
67 55
 
56
+    @discardableResult
57
+    func seedDeviceProfilesCatalog(_ profiles: [DeviceProfileDefinition]) -> Bool {
58
+        var didSave = false
59
+        context.performAndWait {
60
+            let now = Date()
61
+            let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.deviceProfile)
62
+            request.predicate = NSPredicate(format: "isCustom == NO OR isCustom == nil")
63
+            let existing = (try? context.fetch(request)) ?? []
64
+            let existingByID: [String: NSManagedObject] = Dictionary(
65
+                grouping: existing,
66
+                by: { stringValue($0, key: "id") ?? "" }
67
+            ).compactMapValues { $0.first }
68
+
69
+            for profile in profiles {
70
+                let target: NSManagedObject
71
+                if let row = existingByID[profile.id] {
72
+                    target = row
73
+                } else {
74
+                    target = NSEntityDescription.insertNewObject(
75
+                        forEntityName: EntityName.deviceProfile,
76
+                        into: context
77
+                    )
78
+                    setValue(profile.id, on: target, key: "id")
79
+                    setValue(now, on: target, key: "createdAt")
80
+                }
81
+                applyCatalogProfile(profile, to: target, updatedAt: now)
82
+            }
83
+
84
+            didSave = saveContext()
85
+        }
86
+        return didSave
87
+    }
88
+
89
+    /// Backfills `profileID` on all ChargedDevice and Powerbank rows that lack one,
90
+    /// and promotes legacy `ChargedDevice` rows with `deviceClass == "powerbank"` to
91
+    /// the dedicated `Powerbank` entity. Idempotent — re-running has no effect once
92
+    /// every row has a `profileID` and no legacy powerbank-class rows remain.
93
+    @discardableResult
94
+    func migrateDevicesToProfiles() -> Bool {
95
+        var didSave = false
96
+        context.performAndWait {
97
+            let now = Date()
98
+            var didChange = false
99
+
100
+            // 1. ChargedDevices (including chargers): assign profileID where missing.
101
+            let deviceRequest = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
102
+            let devices = (try? context.fetch(deviceRequest)) ?? []
103
+
104
+            // 1a. Promote legacy class-`.powerbank` rows to the Powerbank entity.
105
+            for device in devices {
106
+                guard stringValue(device, key: "deviceClassRawValue") == ChargedDeviceClass.powerbank.rawValue else {
107
+                    continue
108
+                }
109
+                promoteLegacyPowerbankDevice(device, now: now)
110
+                didChange = true
111
+            }
112
+
113
+            // 1b. Backfill profileID for the remaining (non-powerbank-class) devices.
114
+            for device in devices where !device.isDeleted {
115
+                guard stringValue(device, key: "profileID") == nil else { continue }
116
+                guard stringValue(device, key: "deviceClassRawValue") != ChargedDeviceClass.powerbank.rawValue else {
117
+                    // Already handled (and deleted) in 1a.
118
+                    continue
119
+                }
120
+
121
+                let assignedProfileID = resolveProfileIDForLegacyDevice(device, now: now)
122
+                setValue(assignedProfileID, on: device, key: "profileID")
123
+                setValue(now, on: device, key: "updatedAt")
124
+                didChange = true
125
+            }
126
+
127
+            // 2. Powerbanks: backfill profileID where missing.
128
+            let powerbankRequest = NSFetchRequest<NSManagedObject>(entityName: EntityName.powerbank)
129
+            let powerbanks = (try? context.fetch(powerbankRequest)) ?? []
130
+            for powerbank in powerbanks where !powerbank.isDeleted {
131
+                guard stringValue(powerbank, key: "profileID") == nil else { continue }
132
+
133
+                let templateID = stringValue(powerbank, key: "deviceTemplateID")
134
+                let assignedProfileID: String
135
+                if let templateID,
136
+                   let catalog = DeviceProfileCatalog.shared.profile(id: templateID),
137
+                   catalog.category == .powerbank {
138
+                    assignedProfileID = templateID
139
+                } else {
140
+                    assignedProfileID = "generic-powerbank"
141
+                }
142
+                setValue(assignedProfileID, on: powerbank, key: "profileID")
143
+                setValue(now, on: powerbank, key: "updatedAt")
144
+                didChange = true
145
+            }
146
+
147
+            if didChange {
148
+                didSave = saveContext()
149
+            } else {
150
+                didSave = true // nothing to do counts as success
151
+            }
152
+        }
153
+        return didSave
154
+    }
155
+
156
+    private func promoteLegacyPowerbankDevice(_ device: NSManagedObject, now: Date) {
157
+        let legacyID = stringValue(device, key: "id")
158
+        let powerbank = NSEntityDescription.insertNewObject(
159
+            forEntityName: EntityName.powerbank,
160
+            into: context
161
+        )
162
+        setValue(legacyID ?? UUID().uuidString, on: powerbank, key: "id")
163
+        setValue(stringValue(device, key: "name"), on: powerbank, key: "name")
164
+        setValue(stringValue(device, key: "qrIdentifier"), on: powerbank, key: "qrIdentifier")
165
+        setValue(stringValue(device, key: "notes"), on: powerbank, key: "notes")
166
+        setValue("none", on: powerbank, key: "batteryLevelReportingRawValue")
167
+        setValue(Int16(0), on: powerbank, key: "batteryBarsCount")
168
+        setValue(optionalDoubleValue(device, key: "estimatedBatteryCapacityWh") ?? 0, on: powerbank, key: "estimatedBatteryCapacityWh")
169
+        setValue(optionalDoubleValue(device, key: "minimumCurrentAmps") ?? 0, on: powerbank, key: "minimumCurrentAmps")
170
+        setValue("generic-powerbank", on: powerbank, key: "profileID")
171
+        setValue(stringValue(device, key: "deviceTemplateID"), on: powerbank, key: "deviceTemplateID")
172
+        setValue(dateValue(device, key: "createdAt") ?? now, on: powerbank, key: "createdAt")
173
+        setValue(now, on: powerbank, key: "updatedAt")
174
+
175
+        track("ChargeInsightsStore: promoted legacy class-.powerbank ChargedDevice (\(legacyID ?? "?")) to Powerbank entity")
176
+        context.delete(device)
177
+    }
178
+
179
+    private func resolveProfileIDForLegacyDevice(_ device: NSManagedObject, now: Date) -> String {
180
+        // 1. Direct catalog match by template ID (apple-iphone, apple-watch, etc.).
181
+        if let templateID = stringValue(device, key: "deviceTemplateID"),
182
+           DeviceProfileCatalog.shared.profile(id: templateID) != nil {
183
+            return templateID
184
+        }
185
+
186
+        // 2. For chargers, map chargerType to a catalog charger profile.
187
+        if stringValue(device, key: "deviceClassRawValue") == ChargedDeviceClass.charger.rawValue {
188
+            if let chargerTypeRaw = stringValue(device, key: "chargerTypeRawValue"),
189
+               let chargerType = ChargerType(rawValue: chargerTypeRaw) {
190
+                switch chargerType {
191
+                case .appleMagSafe: return "apple-magsafe-charger"
192
+                case .appleWatch: return "apple-watch-charger"
193
+                case .genericMagSafe: return "generic-magsafe-charger"
194
+                case .genericQi: return "generic-qi-charger"
195
+                }
196
+            }
197
+            return "generic-qi-charger"
198
+        }
199
+
200
+        // 3. Synthesize a custom DeviceProfile row matching the device's persisted state.
201
+        return synthesizeCustomProfile(from: device, now: now)
202
+    }
203
+
204
+    private func synthesizeCustomProfile(from device: NSManagedObject, now: Date) -> String {
205
+        let id = UUID().uuidString
206
+        let deviceName = stringValue(device, key: "name") ?? "Untitled"
207
+        let profileName = "\(deviceName) profile"
208
+
209
+        let classRaw = stringValue(device, key: "deviceClassRawValue") ?? ChargedDeviceClass.other.rawValue
210
+        let deviceClass = ChargedDeviceClass(rawValue: classRaw) ?? .other
211
+        let category = ProfileCategory.fromLegacyDeviceClass(deviceClass)
212
+
213
+        let supportsWired = (device.value(forKey: "supportsWiredCharging") as? Bool) ?? false
214
+        let supportsWireless = (device.value(forKey: "supportsWirelessCharging") as? Bool) ?? false
215
+        let chargingStateRaw = stringValue(device, key: "chargingStateAvailabilityRawValue") ?? ChargingStateAvailability.onOrOff.rawValue
216
+        let chargingState = ChargingStateAvailability(rawValue: chargingStateRaw) ?? .onOrOff
217
+        let wirelessProfileRaw = stringValue(device, key: "wirelessChargingProfileRawValue") ?? WirelessChargingProfile.genericQi.rawValue
218
+        let wirelessProfile = WirelessChargingProfile(rawValue: wirelessProfileRaw) ?? .genericQi
219
+
220
+        let allowedWirelessProfiles = supportsWireless ? [wirelessProfile] : []
221
+
222
+        let profile = NSEntityDescription.insertNewObject(
223
+            forEntityName: EntityName.deviceProfile,
224
+            into: context
225
+        )
226
+        setValue(id, on: profile, key: "id")
227
+        setValue(profileName, on: profile, key: "name")
228
+        setValue(category.rawValue, on: profile, key: "categoryRawValue")
229
+        setValue(category.symbolName, on: profile, key: "iconSymbolName")
230
+        setValue(true, on: profile, key: "isCustom")
231
+        setValue(Int16(1), on: profile, key: "schemaVersion")
232
+        setValue(Int32(1000), on: profile, key: "sortOrder")
233
+        setValue("Custom", on: profile, key: "group")
234
+        setValue(supportsWired, on: profile, key: "capWiredCharging")
235
+        setValue(supportsWireless, on: profile, key: "capWirelessCharging")
236
+        setValue(allowedWirelessProfiles.map { $0.rawValue }.joined(separator: ","), on: profile, key: "capWirelessProfilesRawValue")
237
+        setValue(chargingState.rawValue, on: profile, key: "capChargingStateAvailabilityRawValue")
238
+        setValue(false, on: profile, key: "capHasInternalSubject")
239
+        setValue(supportsWireless ? wirelessProfile.rawValue : nil, on: profile, key: "defaultWirelessChargingProfileRawValue")
240
+        setValue(now, on: profile, key: "createdAt")
241
+        setValue(now, on: profile, key: "updatedAt")
242
+
243
+        track("ChargeInsightsStore: synthesized custom DeviceProfile \(id) for legacy device \(stringValue(device, key: "id") ?? "?")")
244
+        return id
245
+    }
246
+
247
+    private func applyCatalogProfile(
248
+        _ profile: DeviceProfileDefinition,
249
+        to object: NSManagedObject,
250
+        updatedAt: Date
251
+    ) {
252
+        setValue(profile.name, on: object, key: "name")
253
+        setValue(profile.category.rawValue, on: object, key: "categoryRawValue")
254
+        setValue(profile.icon.name, on: object, key: "iconSymbolName")
255
+        setValue(profile.icon.fallbackSystemName, on: object, key: "iconFallbackSymbolName")
256
+        setValue(false, on: object, key: "isCustom")
257
+        setValue(Int16(1), on: object, key: "schemaVersion")
258
+        setValue(Int32(profile.sortOrder), on: object, key: "sortOrder")
259
+        setValue(profile.group, on: object, key: "group")
260
+        setValue(profile.capWiredCharging, on: object, key: "capWiredCharging")
261
+        setValue(profile.capWirelessCharging, on: object, key: "capWirelessCharging")
262
+        setValue(profile.wirelessProfilesCSV, on: object, key: "capWirelessProfilesRawValue")
263
+        setValue(profile.capChargingStateAvailability.rawValue, on: object, key: "capChargingStateAvailabilityRawValue")
264
+        setValue(profile.capHasInternalSubject, on: object, key: "capHasInternalSubject")
265
+        setValue(profile.defaultWirelessChargingProfile?.rawValue, on: object, key: "defaultWirelessChargingProfileRawValue")
266
+        setValue(profile.defaultWiredMinimumCurrentAmps ?? 0, on: object, key: "defaultWiredMinimumCurrentAmps")
267
+        setValue(profile.defaultWirelessMinimumCurrentAmps ?? 0, on: object, key: "defaultWirelessMinimumCurrentAmps")
268
+        setValue(profile.defaultWiredEstimatedBatteryCapacityWh ?? 0, on: object, key: "defaultWiredEstimatedBatteryCapacityWh")
269
+        setValue(profile.defaultWirelessEstimatedBatteryCapacityWh ?? 0, on: object, key: "defaultWirelessEstimatedBatteryCapacityWh")
270
+        setValue(updatedAt, on: object, key: "updatedAt")
271
+    }
272
+
68 273
     @discardableResult
69 274
     func flushPendingChanges() -> Bool {
70 275
         var didSave = false
@@ -185,13 +390,14 @@ final class ChargeInsightsStore {
185 390
         name: String,
186 391
         deviceClass: ChargedDeviceClass,
187 392
         templateID: String?,
393
+        profileID: String? = nil,
394
+        hasInternalSubject: Bool = false,
188 395
         chargingStateAvailability: ChargingStateAvailability,
189 396
         supportsWiredCharging: Bool,
190 397
         supportsWirelessCharging: Bool,
191 398
         wirelessChargingProfile: WirelessChargingProfile,
192 399
         configuredCompletionCurrents: [ChargeSessionKind: Double],
193
-        notes: String?,
194
-        assignTo meterMACAddress: String?
400
+        notes: String?
195 401
     ) -> Bool {
196 402
         guard deviceClass.kind == .device else { return false }
197 403
         let normalizedName = normalizedText(name)
@@ -201,6 +407,7 @@ final class ChargeInsightsStore {
201 407
             supportsWirelessCharging: supportsWirelessCharging
202 408
         )
203 409
         let normalizedTemplateID = normalizedTemplateID(templateID, kind: .device)
410
+        let normalizedProfileID = normalizedOptionalText(profileID)
204 411
         guard !normalizedName.isEmpty else { return false }
205 412
         guard normalizedChargingSupport.wired || normalizedChargingSupport.wireless else { return false }
206 413
 
@@ -216,6 +423,8 @@ final class ChargeInsightsStore {
216 423
             object.setValue(normalizedName, forKey: "name")
217 424
             object.setValue(deviceClass.rawValue, forKey: "deviceClassRawValue")
218 425
             object.setValue(normalizedTemplateID, forKey: "deviceTemplateID")
426
+            object.setValue(normalizedProfileID, forKey: "profileID")
427
+            object.setValue(hasInternalSubject, forKey: "hasInternalSubject")
219 428
             object.setValue(normalizedChargingStateAvailability.rawValue, forKey: "chargingStateAvailabilityRawValue")
220 429
             object.setValue(normalizedChargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
221 430
             object.setValue(normalizedChargingSupport.wired, forKey: "supportsWiredCharging")
@@ -226,7 +435,6 @@ final class ChargeInsightsStore {
226 435
             object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wireless), forKey: "wirelessChargeCompletionCurrentAmps")
227 436
             object.setValue(normalizedOptionalText(notes), forKey: "notes")
228 437
             object.setValue(generateQRIdentifier(), forKey: "qrIdentifier")
229
-            object.setValue(normalizedOptionalText(meterMACAddress), forKey: "lastAssociatedMeterMAC")
230 438
             object.setValue(now, forKey: "createdAt")
231 439
             object.setValue(now, forKey: "updatedAt")
232 440
             didSave = saveContext()
@@ -235,11 +443,104 @@ final class ChargeInsightsStore {
235 443
     }
236 444
 
237 445
     @discardableResult
446
+    func createPowerbank(
447
+        name: String,
448
+        templateID: String?,
449
+        batteryLevelReporting: BatteryLevelReporting,
450
+        batteryBarsCount: Int,
451
+        notes: String?
452
+    ) -> Bool {
453
+        let normalizedName = normalizedText(name)
454
+        guard !normalizedName.isEmpty else { return false }
455
+
456
+        var didSave = false
457
+        context.performAndWait {
458
+            guard let entity = NSEntityDescription.entity(forEntityName: EntityName.powerbank, in: context) else {
459
+                return
460
+            }
461
+
462
+            let object = NSManagedObject(entity: entity, insertInto: context)
463
+            let now = Date()
464
+            object.setValue(UUID().uuidString, forKey: "id")
465
+            object.setValue(normalizedName, forKey: "name")
466
+            object.setValue(generateQRIdentifier(), forKey: "qrIdentifier")
467
+            object.setValue(normalizedTemplateID(templateID, kind: .device), forKey: "deviceTemplateID")
468
+            object.setValue(batteryLevelReporting.rawValue, forKey: "batteryLevelReportingRawValue")
469
+            object.setValue(Int16(batteryLevelReporting == .bars ? max(1, batteryBarsCount) : 0), forKey: "batteryBarsCount")
470
+            object.setValue(normalizedOptionalText(notes), forKey: "notes")
471
+            object.setValue(now, forKey: "createdAt")
472
+            object.setValue(now, forKey: "updatedAt")
473
+            didSave = saveContext()
474
+        }
475
+        return didSave
476
+    }
477
+
478
+    @discardableResult
479
+    func updatePowerbank(
480
+        id: UUID,
481
+        name: String,
482
+        templateID: String?,
483
+        batteryLevelReporting: BatteryLevelReporting,
484
+        batteryBarsCount: Int,
485
+        notes: String?
486
+    ) -> Bool {
487
+        let normalizedName = normalizedText(name)
488
+        guard !normalizedName.isEmpty else { return false }
489
+
490
+        var didSave = false
491
+        context.performAndWait {
492
+            guard let object = fetchPowerbankObject(id: id.uuidString) else {
493
+                return
494
+            }
495
+
496
+            let now = Date()
497
+            object.setValue(normalizedName, forKey: "name")
498
+            object.setValue(normalizedTemplateID(templateID, kind: .device), forKey: "deviceTemplateID")
499
+            object.setValue(batteryLevelReporting.rawValue, forKey: "batteryLevelReportingRawValue")
500
+            object.setValue(Int16(batteryLevelReporting == .bars ? max(1, batteryBarsCount) : 0), forKey: "batteryBarsCount")
501
+            object.setValue(normalizedOptionalText(notes), forKey: "notes")
502
+            object.setValue(now, forKey: "updatedAt")
503
+            didSave = saveContext()
504
+        }
505
+        return didSave
506
+    }
507
+
508
+    @discardableResult
509
+    func deletePowerbank(id powerbankID: UUID) -> Bool {
510
+        var didSave = false
511
+
512
+        context.performAndWait {
513
+            guard let powerbank = fetchPowerbankObject(id: powerbankID.uuidString) else {
514
+                return
515
+            }
516
+
517
+            // Detach references from any session that mentions this powerbank as subject or source.
518
+            let subjectSessions = fetchSessions(forPowerbankSubjectID: powerbankID.uuidString)
519
+            for session in subjectSessions {
520
+                if let sessionID = stringValue(session, key: "id") {
521
+                    fetchCheckpointObjects(forSessionID: sessionID).forEach(context.delete)
522
+                    fetchSessionSampleObjects(forSessionID: sessionID).forEach(context.delete)
523
+                }
524
+                context.delete(session)
525
+            }
526
+
527
+            let sourceSessions = fetchSessions(forPowerbankSourceID: powerbankID.uuidString)
528
+            for session in sourceSessions {
529
+                session.setValue(nil, forKey: "sourcePowerbankID")
530
+                session.setValue(Date(), forKey: "updatedAt")
531
+            }
532
+
533
+            context.delete(powerbank)
534
+            didSave = saveContext()
535
+        }
536
+
537
+        return didSave
538
+    }
539
+
238 540
     func createCharger(
239 541
         name: String,
240 542
         chargerType: ChargerType,
241
-        notes: String?,
242
-        assignTo meterMACAddress: String?
543
+        notes: String?
243 544
     ) -> Bool {
244 545
         let normalizedName = normalizedText(name)
245 546
         guard !normalizedName.isEmpty else { return false }
@@ -269,7 +570,6 @@ final class ChargeInsightsStore {
269 570
             object.setValue(nil, forKey: "wirelessChargeCompletionCurrentAmps")
270 571
             object.setValue(normalizedOptionalText(notes), forKey: "notes")
271 572
             object.setValue(generateQRIdentifier(), forKey: "qrIdentifier")
272
-            object.setValue(normalizedOptionalText(meterMACAddress), forKey: "lastAssociatedMeterMAC")
273 573
             object.setValue(now, forKey: "createdAt")
274 574
             object.setValue(now, forKey: "updatedAt")
275 575
             didSave = saveContext()
@@ -283,6 +583,8 @@ final class ChargeInsightsStore {
283 583
         name: String,
284 584
         deviceClass: ChargedDeviceClass,
285 585
         templateID: String?,
586
+        profileID: String? = nil,
587
+        hasInternalSubject: Bool = false,
286 588
         chargingStateAvailability: ChargingStateAvailability,
287 589
         supportsWiredCharging: Bool,
288 590
         supportsWirelessCharging: Bool,
@@ -298,6 +600,7 @@ final class ChargeInsightsStore {
298 600
             supportsWirelessCharging: supportsWirelessCharging
299 601
         )
300 602
         let normalizedTemplateID = normalizedTemplateID(templateID, kind: .device)
603
+        let normalizedProfileID = normalizedOptionalText(profileID)
301 604
         guard !normalizedName.isEmpty else { return false }
302 605
         guard normalizedChargingSupport.wired || normalizedChargingSupport.wireless else { return false }
303 606
 
@@ -319,6 +622,8 @@ final class ChargeInsightsStore {
319 622
             object.setValue(normalizedName, forKey: "name")
320 623
             object.setValue(deviceClass.rawValue, forKey: "deviceClassRawValue")
321 624
             object.setValue(normalizedTemplateID, forKey: "deviceTemplateID")
625
+            object.setValue(normalizedProfileID, forKey: "profileID")
626
+            object.setValue(hasInternalSubject, forKey: "hasInternalSubject")
322 627
             object.setValue(normalizedChargingStateAvailability.rawValue, forKey: "chargingStateAvailabilityRawValue")
323 628
             object.setValue(normalizedChargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
324 629
             object.setValue(normalizedChargingSupport.wired, forKey: "supportsWiredCharging")
@@ -425,72 +730,12 @@ final class ChargeInsightsStore {
425 730
         return didSave
426 731
     }
427 732
 
428
-    @discardableResult
429
-    func assignChargedDevice(id: UUID, to meterMACAddress: String) -> Bool {
430
-        assign(itemWithID: id, to: meterMACAddress, kind: .chargedDevice)
431
-    }
432
-
433
-    @discardableResult
434
-    func assignCharger(id: UUID, to meterMACAddress: String) -> Bool {
435
-        assign(itemWithID: id, to: meterMACAddress, kind: .charger)
436
-    }
437
-
438
-    @discardableResult
439
-    private func assign(
440
-        itemWithID id: UUID,
441
-        to meterMACAddress: String,
442
-        kind: MeterAssignmentKind
443
-    ) -> Bool {
444
-        let normalizedMAC = normalizedMACAddress(meterMACAddress)
445
-        guard !normalizedMAC.isEmpty else { return false }
446
-
447
-        var didSave = false
448
-        context.performAndWait {
449
-            guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
450
-                return
451
-            }
452
-
453
-            let isCharger = ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
454
-            guard isCharger == kind.expectsChargerClass else {
455
-                return
456
-            }
457
-
458
-            let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
459
-            request.predicate = NSPredicate(
460
-                format: "lastAssociatedMeterMAC == %@ AND id != %@",
461
-                normalizedMAC,
462
-                id.uuidString
463
-            )
464
-            let previouslyAssignedDevices = (try? context.fetch(request)) ?? []
465
-            for previousDevice in previouslyAssignedDevices {
466
-                let previousIsCharger = ChargedDeviceClass(rawValue: stringValue(previousDevice, key: "deviceClassRawValue") ?? "") == .charger
467
-                guard previousIsCharger == kind.expectsChargerClass else {
468
-                    continue
469
-                }
470
-                previousDevice.setValue(nil, forKey: "lastAssociatedMeterMAC")
471
-                previousDevice.setValue(Date(), forKey: "updatedAt")
472
-            }
473
-
474
-            object.setValue(normalizedMAC, forKey: "lastAssociatedMeterMAC")
475
-            object.setValue(Date(), forKey: "updatedAt")
476
-
477
-            if kind == .charger,
478
-               let openSession = fetchOpenSessionObject(forMeterMACAddress: normalizedMAC),
479
-               chargingTransportMode(for: openSession) == .wireless {
480
-                openSession.setValue(id.uuidString, forKey: "chargerID")
481
-                openSession.setValue(Date(), forKey: "updatedAt")
482
-            }
483
-
484
-            didSave = saveContext()
485
-        }
486
-        return didSave
487
-    }
488
-
489 733
     @discardableResult
490 734
     func startSession(
491 735
         for snapshot: ChargingMonitorSnapshot,
492 736
         chargedDeviceID: UUID,
493 737
         chargerID: UUID?,
738
+        sourcePowerbankID: UUID? = nil,
494 739
         chargingTransportMode: ChargingTransportMode,
495 740
         chargingStateMode: ChargingStateMode,
496 741
         autoStopEnabled: Bool,
@@ -530,7 +775,9 @@ final class ChargeInsightsStore {
530 775
             if let charger, isChargerObject(charger) == false {
531 776
                 return
532 777
             }
533
-            guard resolvedChargingTransportMode == .wired || charger != nil else {
778
+            let powerbankSource = sourcePowerbankID.flatMap { fetchPowerbankObject(id: $0.uuidString) }
779
+            // Wireless transport historically required a charger; accept a powerbank in its place.
780
+            guard resolvedChargingTransportMode == .wired || charger != nil || powerbankSource != nil else {
534 781
                 return
535 782
             }
536 783
             let stopThreshold = resolvedStopThreshold(
@@ -551,6 +798,70 @@ final class ChargeInsightsStore {
551 798
             ) else {
552 799
                 return
553 800
             }
801
+            if let powerbankSource, let powerbankIDString = stringValue(powerbankSource, key: "id") {
802
+                session.setValue(powerbankIDString, forKey: "sourcePowerbankID")
803
+            }
804
+
805
+            if startsFromFlatBattery {
806
+                session.setValue(unresolvedFlatBatteryPercent, forKey: "startBatteryPercent")
807
+                session.setValue(nil, forKey: "endBatteryPercent")
808
+            } else if let initialBatteryPercent {
809
+                guard insertBatteryCheckpoint(
810
+                    percent: initialBatteryPercent,
811
+                    flag: .initial,
812
+                    timestamp: snapshot.observedAt,
813
+                    to: session
814
+                ) != nil else {
815
+                    return
816
+                }
817
+            }
818
+            didSave = saveContext()
819
+        }
820
+        return didSave
821
+    }
822
+
823
+    @discardableResult
824
+    func startPowerbankSession(
825
+        for snapshot: ChargingMonitorSnapshot,
826
+        powerbankID: UUID,
827
+        sourcePowerbankID: UUID? = nil,
828
+        autoStopEnabled: Bool,
829
+        initialBatteryPercent: Double?,
830
+        startsFromFlatBattery: Bool
831
+    ) -> Bool {
832
+        if let initialBatteryPercent,
833
+           (initialBatteryPercent.isFinite == false || initialBatteryPercent < 0 || initialBatteryPercent > 100) {
834
+            return false
835
+        }
836
+
837
+        var didSave = false
838
+        context.performAndWait {
839
+            guard let powerbank = fetchPowerbankObject(id: powerbankID.uuidString) else {
840
+                return
841
+            }
842
+
843
+            guard fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress) == nil else {
844
+                return
845
+            }
846
+
847
+            let powerbankSource = sourcePowerbankID == powerbankID
848
+                ? nil
849
+                : sourcePowerbankID.flatMap { fetchPowerbankObject(id: $0.uuidString) }
850
+            let stopThreshold = optionalDoubleValue(powerbank, key: "configuredCompletionCurrentAmps")
851
+                ?? optionalDoubleValue(powerbank, key: "learnedCompletionCurrentAmps")
852
+                ?? (snapshot.fallbackStopThresholdAmps > 0 ? snapshot.fallbackStopThresholdAmps : nil)
853
+
854
+            guard let session = createPowerbankSubjectSessionObject(
855
+                for: powerbank,
856
+                snapshot: snapshot,
857
+                stopThreshold: stopThreshold,
858
+                autoStopEnabled: autoStopEnabled
859
+            ) else {
860
+                return
861
+            }
862
+            if let powerbankSource, let powerbankIDString = stringValue(powerbankSource, key: "id") {
863
+                session.setValue(powerbankIDString, forKey: "sourcePowerbankID")
864
+            }
554 865
 
555 866
             if startsFromFlatBattery {
556 867
                 session.setValue(unresolvedFlatBatteryPercent, forKey: "startBatteryPercent")
@@ -560,6 +871,7 @@ final class ChargeInsightsStore {
560 871
                     percent: initialBatteryPercent,
561 872
                     flag: .initial,
562 873
                     timestamp: snapshot.observedAt,
874
+                    subject: .powerbank,
563 875
                     to: session
564 876
                 ) != nil else {
565 877
                     return
@@ -707,8 +1019,7 @@ final class ChargeInsightsStore {
707 1019
     func addBatteryCheckpoint(
708 1020
         percent: Double,
709 1021
         for meterMACAddress: String,
710
-        measuredEnergyWh: Double? = nil,
711
-        measuredChargeAh: Double? = nil
1022
+        measuredEnergyWh: Double? = nil
712 1023
     ) -> Bool {
713 1024
         guard percent.isFinite, percent >= 0, percent <= 100 else {
714 1025
             return false
@@ -723,7 +1034,6 @@ final class ChargeInsightsStore {
723 1034
             didSave = addBatteryCheckpoint(
724 1035
                 percent: percent,
725 1036
                 measuredEnergyWh: measuredEnergyWh,
726
-                measuredChargeAh: measuredChargeAh,
727 1037
                 flag: .intermediate,
728 1038
                 to: session
729 1039
             )
@@ -736,7 +1046,8 @@ final class ChargeInsightsStore {
736 1046
         percent: Double,
737 1047
         for sessionID: UUID,
738 1048
         measuredEnergyWh: Double? = nil,
739
-        measuredChargeAh: Double? = nil
1049
+        subject: CheckpointSubject = .chargedDevice,
1050
+        barsValue: Int = 0
740 1051
     ) -> Bool {
741 1052
         guard percent.isFinite, percent >= 0, percent <= 100 else {
742 1053
             return false
@@ -751,8 +1062,9 @@ final class ChargeInsightsStore {
751 1062
             didSave = addBatteryCheckpoint(
752 1063
                 percent: percent,
753 1064
                 measuredEnergyWh: measuredEnergyWh,
754
-                measuredChargeAh: measuredChargeAh,
755 1065
                 flag: .intermediate,
1066
+                subject: subject,
1067
+                barsValue: barsValue,
756 1068
                 to: session
757 1069
             )
758 1070
         }
@@ -957,6 +1269,146 @@ final class ChargeInsightsStore {
957 1269
         return didSave
958 1270
     }
959 1271
 
1272
+    @discardableResult
1273
+    func commitSessionTrim(sessionID: UUID) -> Bool {
1274
+        var didSave = false
1275
+        context.performAndWait {
1276
+            guard let session = fetchSessionObject(id: sessionID.uuidString),
1277
+                  statusValue(session, key: "statusRawValue")?.isOpen == false else {
1278
+                return
1279
+            }
1280
+
1281
+            guard dateValue(session, key: "trimStart") != nil
1282
+                    || dateValue(session, key: "trimEnd") != nil else {
1283
+                return
1284
+            }
1285
+
1286
+            let sessionStart = dateValue(session, key: "startedAt") ?? Date.distantPast
1287
+            let sessionEnd = dateValue(session, key: "endedAt")
1288
+                ?? dateValue(session, key: "lastObservedAt")
1289
+                ?? sessionStart
1290
+
1291
+            let effectiveStart = min(max(dateValue(session, key: "trimStart") ?? sessionStart, sessionStart), sessionEnd)
1292
+            let effectiveEnd = max(
1293
+                min(dateValue(session, key: "trimEnd") ?? sessionEnd, sessionEnd),
1294
+                effectiveStart
1295
+            )
1296
+
1297
+            let sampleObjects = fetchSessionSampleObjects(forSessionID: sessionID.uuidString)
1298
+            let allSamples = sampleObjects
1299
+                .compactMap { obj -> (timestamp: Date, energy: Double, charge: Double)? in
1300
+                    guard let timestamp = dateValue(obj, key: "timestamp") else { return nil }
1301
+                    return (
1302
+                        timestamp: timestamp,
1303
+                        energy: doubleValue(obj, key: "measuredEnergyWh"),
1304
+                        charge: doubleValue(obj, key: "measuredChargeAh")
1305
+                    )
1306
+                }
1307
+                .sorted { $0.timestamp < $1.timestamp }
1308
+
1309
+            let baselineSample = allSamples.last { $0.timestamp <= effectiveStart }
1310
+            let endSample = allSamples.last { $0.timestamp <= effectiveEnd }
1311
+            let baselineEnergy = baselineSample?.energy ?? 0
1312
+            let baselineCharge = baselineSample?.charge ?? 0
1313
+            let committedEnergy = endSample.map { max($0.energy - baselineEnergy, 0) }
1314
+                ?? doubleValue(session, key: "measuredEnergyWh")
1315
+            let committedCharge = endSample.map { max($0.charge - baselineCharge, 0) }
1316
+                ?? doubleValue(session, key: "measuredChargeAh")
1317
+
1318
+            var retainedSamples: [(current: Double, power: Double, voltage: Double?)] = []
1319
+            for sample in sampleObjects {
1320
+                guard let timestamp = dateValue(sample, key: "timestamp") else {
1321
+                    context.delete(sample)
1322
+                    continue
1323
+                }
1324
+
1325
+                guard timestamp >= effectiveStart && timestamp <= effectiveEnd else {
1326
+                    context.delete(sample)
1327
+                    continue
1328
+                }
1329
+
1330
+                let rebasedEnergy = max(doubleValue(sample, key: "measuredEnergyWh") - baselineEnergy, 0)
1331
+                let rebasedCharge = max(doubleValue(sample, key: "measuredChargeAh") - baselineCharge, 0)
1332
+                let elapsed = max(timestamp.timeIntervalSince(effectiveStart), 0)
1333
+                let rebasedBucketIndex = Int32(floor(elapsed / Self.aggregatedSampleBucketDuration))
1334
+
1335
+                sample.setValue("\(sessionID.uuidString)-\(rebasedBucketIndex)", forKey: "id")
1336
+                sample.setValue(rebasedBucketIndex, forKey: "bucketIndex")
1337
+                sample.setValue(rebasedEnergy, forKey: "measuredEnergyWh")
1338
+                sample.setValue(rebasedCharge, forKey: "measuredChargeAh")
1339
+                sample.setValue(Date(), forKey: "updatedAt")
1340
+
1341
+                retainedSamples.append(
1342
+                    (
1343
+                        current: doubleValue(sample, key: "averageCurrentAmps"),
1344
+                        power: doubleValue(sample, key: "averagePowerWatts"),
1345
+                        voltage: optionalDoubleValue(sample, key: "averageVoltageVolts")
1346
+                    )
1347
+                )
1348
+            }
1349
+
1350
+            for checkpoint in fetchCheckpointObjects(forSessionID: sessionID.uuidString) {
1351
+                guard let timestamp = dateValue(checkpoint, key: "timestamp") else {
1352
+                    context.delete(checkpoint)
1353
+                    continue
1354
+                }
1355
+
1356
+                guard timestamp >= effectiveStart && timestamp <= effectiveEnd else {
1357
+                    context.delete(checkpoint)
1358
+                    continue
1359
+                }
1360
+
1361
+                let checkpointSample = allSamples.last { $0.timestamp <= timestamp }
1362
+                checkpoint.setValue(
1363
+                    max((checkpointSample?.energy ?? baselineEnergy) - baselineEnergy, 0),
1364
+                    forKey: "measuredEnergyWh"
1365
+                )
1366
+                checkpoint.setValue(
1367
+                    max((checkpointSample?.charge ?? baselineCharge) - baselineCharge, 0),
1368
+                    forKey: "measuredChargeAh"
1369
+                )
1370
+            }
1371
+
1372
+            if !retainedSamples.isEmpty {
1373
+                let positiveCurrents = retainedSamples.map { $0.current }.filter { $0 > 0 }
1374
+                session.setValue(positiveCurrents.min(), forKey: "minimumObservedCurrentAmps")
1375
+                session.setValue(retainedSamples.map { $0.current }.max(), forKey: "maximumObservedCurrentAmps")
1376
+                session.setValue(retainedSamples.map { $0.power }.max(), forKey: "maximumObservedPowerWatts")
1377
+                session.setValue(retainedSamples.compactMap { $0.voltage }.max(), forKey: "maximumObservedVoltageVolts")
1378
+                session.setValue(
1379
+                    retainedSamples.contains { $0.power > 0.05 || $0.current > 0.01 },
1380
+                    forKey: "hasObservedChargeFlow"
1381
+                )
1382
+            } else {
1383
+                session.setValue(nil, forKey: "minimumObservedCurrentAmps")
1384
+                session.setValue(nil, forKey: "maximumObservedCurrentAmps")
1385
+                session.setValue(nil, forKey: "maximumObservedPowerWatts")
1386
+                session.setValue(nil, forKey: "maximumObservedVoltageVolts")
1387
+                session.setValue(committedEnergy > 0 || committedCharge > 0, forKey: "hasObservedChargeFlow")
1388
+            }
1389
+
1390
+            session.setValue(effectiveStart, forKey: "startedAt")
1391
+            session.setValue(effectiveEnd, forKey: "lastObservedAt")
1392
+            if dateValue(session, key: "endedAt") != nil {
1393
+                session.setValue(effectiveEnd, forKey: "endedAt")
1394
+            }
1395
+            session.setValue(committedEnergy, forKey: "measuredEnergyWh")
1396
+            session.setValue(committedCharge, forKey: "measuredChargeAh")
1397
+            session.setValue(nil, forKey: "trimStart")
1398
+            session.setValue(nil, forKey: "trimEnd")
1399
+            session.setValue(Date(), forKey: "updatedAt")
1400
+
1401
+            refreshCheckpointDerivedValues(for: session)
1402
+
1403
+            if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
1404
+                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1405
+            }
1406
+
1407
+            didSave = saveContext()
1408
+        }
1409
+        return didSave
1410
+    }
1411
+
960 1412
     @discardableResult
961 1413
     func deleteChargeSession(id sessionID: UUID) -> Bool {
962 1414
         var didSave = false
@@ -1186,6 +1638,8 @@ final class ChargeInsightsStore {
1186 1638
                     deviceClass: deviceClass,
1187 1639
                     deviceTemplateID: stringValue(device, key: "deviceTemplateID"),
1188 1640
                     templateDefinition: templateDefinition,
1641
+                    profileID: stringValue(device, key: "profileID"),
1642
+                    hasInternalSubject: (device.value(forKey: "hasInternalSubject") as? Bool) ?? false,
1189 1643
                     supportsChargingWhileOff: chargingStateAvailability.supportsChargingWhileOff,
1190 1644
                     chargingStateAvailability: chargingStateAvailability,
1191 1645
                     supportsWiredCharging: supportsWiredCharging,
@@ -1208,13 +1662,13 @@ final class ChargeInsightsStore {
1208 1662
                     wirelessMinimumCurrentAmps: optionalDoubleValue(device, key: "wirelessMinimumCurrentAmps"),
1209 1663
                     wiredEstimatedBatteryCapacityWh: optionalDoubleValue(device, key: "wiredEstimatedBatteryCapacityWh"),
1210 1664
                     wirelessEstimatedBatteryCapacityWh: optionalDoubleValue(device, key: "wirelessEstimatedBatteryCapacityWh"),
1211
-                    lastAssociatedMeterMAC: stringValue(device, key: "lastAssociatedMeterMAC"),
1212 1665
                     createdAt: dateValue(device, key: "createdAt") ?? .distantPast,
1213 1666
                     updatedAt: dateValue(device, key: "updatedAt") ?? .distantPast,
1214 1667
                     sessions: sessionSummaries,
1215 1668
                     capacityHistory: buildCapacityHistory(from: sessionSummaries),
1216 1669
                     typicalCurve: buildTypicalCurve(from: sessionSummaries),
1217
-                    standbyPowerMeasurements: []
1670
+                    standbyPowerMeasurements: [],
1671
+                    consumptionSessions: []
1218 1672
                 )
1219 1673
             }
1220 1674
             .sorted { lhs, rhs in
@@ -1234,21 +1688,6 @@ final class ChargeInsightsStore {
1234 1688
         return summaries
1235 1689
     }
1236 1690
 
1237
-    func resolvedChargedDeviceSummary(forMeterMACAddress meterMACAddress: String) -> ChargedDeviceSummary? {
1238
-        let normalizedMAC = normalizedMACAddress(meterMACAddress)
1239
-        guard !normalizedMAC.isEmpty else { return nil }
1240
-
1241
-        let summaries = fetchChargedDeviceSummaries().filter { !$0.isCharger }
1242
-
1243
-        if let activeMatch = summaries.first(where: { summary in
1244
-            summary.activeSession?.meterMACAddress == normalizedMAC
1245
-        }) {
1246
-            return activeMatch
1247
-        }
1248
-
1249
-        return summaries.first(where: { $0.lastAssociatedMeterMAC == normalizedMAC })
1250
-    }
1251
-
1252 1691
     func activeChargeSessionSummary(forMeterMACAddress meterMACAddress: String) -> ChargeSessionSummary? {
1253 1692
         let normalizedMAC = normalizedMACAddress(meterMACAddress)
1254 1693
         guard !normalizedMAC.isEmpty else { return nil }
@@ -1268,21 +1707,211 @@ final class ChargeInsightsStore {
1268 1707
             )
1269 1708
         }
1270 1709
 
1271
-        return summary
1710
+        return summary
1711
+    }
1712
+
1713
+    /// Materialize all Powerbank entities into Swift summaries, with sessions in which the
1714
+    /// powerbank participates either as the charged subject (`chargedPowerbankID`) or as the
1715
+    /// supplying source (`sourcePowerbankID`). Discharge curve aggregation across multiple
1716
+    /// concurrent device-side sessions is derived view-side from `sessionsAsSource`.
1717
+    func fetchPowerbankSummaries() -> [PowerbankSummary] {
1718
+        var summaries: [PowerbankSummary] = []
1719
+
1720
+        context.performAndWait {
1721
+            let powerbanks = fetchObjects(entityName: EntityName.powerbank)
1722
+            guard !powerbanks.isEmpty else {
1723
+                return
1724
+            }
1725
+
1726
+            let sessions = fetchObjects(entityName: EntityName.chargeSession)
1727
+            let checkpoints = fetchObjects(entityName: EntityName.chargeCheckpoint)
1728
+            let checkpointsBySessionID = Dictionary(grouping: checkpoints) {
1729
+                stringValue($0, key: "sessionID") ?? ""
1730
+            }
1731
+            let sessionsAsSubject = Dictionary(grouping: sessions) {
1732
+                stringValue($0, key: "chargedPowerbankID") ?? ""
1733
+            }
1734
+            let sessionsAsSource = Dictionary(grouping: sessions) {
1735
+                stringValue($0, key: "sourcePowerbankID") ?? ""
1736
+            }
1737
+
1738
+            summaries = powerbanks.compactMap { powerbank in
1739
+                guard
1740
+                    let id = uuidValue(powerbank, key: "id"),
1741
+                    let name = stringValue(powerbank, key: "name"),
1742
+                    let qrIdentifier = stringValue(powerbank, key: "qrIdentifier")
1743
+                else {
1744
+                    return nil
1745
+                }
1746
+
1747
+                let templateID = stringValue(powerbank, key: "deviceTemplateID")
1748
+                let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID)
1749
+
1750
+                let reportingRaw = stringValue(powerbank, key: "batteryLevelReportingRawValue")
1751
+                let reporting = reportingRaw.flatMap(BatteryLevelReporting.init(rawValue:)) ?? .percent
1752
+                let barsCount = Int(optionalInt16Value(powerbank, key: "batteryBarsCount") ?? 0)
1753
+
1754
+                let sessionsAsSubjectRaw = sessionsAsSubject[id.uuidString] ?? []
1755
+                let sessionsAsSourceRaw = sessionsAsSource[id.uuidString] ?? []
1756
+
1757
+                let subjectSessions = sessionsAsSubjectRaw
1758
+                    .compactMap { session -> ChargeSessionSummary? in
1759
+                        let sessionID = stringValue(session, key: "id") ?? ""
1760
+                        return makeSessionSummary(
1761
+                            from: session,
1762
+                            checkpoints: checkpointsBySessionID[sessionID] ?? [],
1763
+                            samples: []
1764
+                        )
1765
+                    }
1766
+                    .sorted { $0.startedAt > $1.startedAt }
1767
+
1768
+                let sourceSessions = sessionsAsSourceRaw
1769
+                    .compactMap { session -> ChargeSessionSummary? in
1770
+                        let sessionID = stringValue(session, key: "id") ?? ""
1771
+                        return makeSessionSummary(
1772
+                            from: session,
1773
+                            checkpoints: checkpointsBySessionID[sessionID] ?? [],
1774
+                            samples: []
1775
+                        )
1776
+                    }
1777
+                    .sorted { $0.startedAt > $1.startedAt }
1778
+
1779
+                let observedVoltages: [Double] = (stringValue(powerbank, key: "sourceObservedVoltageSelectionsRawValue") ?? "")
1780
+                    .split(separator: ",")
1781
+                    .compactMap { Double($0) }
1782
+                    .sorted()
1783
+
1784
+                let derived = derivedPowerbankMetrics(
1785
+                    sessionsAsSubject: subjectSessions,
1786
+                    sessionsAsSource: sourceSessions,
1787
+                    reporting: reporting
1788
+                )
1789
+
1790
+                return PowerbankSummary(
1791
+                    id: id,
1792
+                    qrIdentifier: qrIdentifier,
1793
+                    name: name,
1794
+                    deviceTemplateID: templateID,
1795
+                    templateDefinition: templateDefinition,
1796
+                    batteryLevelReporting: reporting,
1797
+                    batteryBarsCount: barsCount,
1798
+                    estimatedBatteryCapacityWh: optionalDoubleValue(powerbank, key: "estimatedBatteryCapacityWh"),
1799
+                    apparentCapacityWh: derived.apparentCapacityWh
1800
+                        ?? optionalDoubleValue(powerbank, key: "apparentCapacityWh"),
1801
+                    configuredCompletionCurrentAmps: optionalDoubleValue(powerbank, key: "configuredCompletionCurrentAmps"),
1802
+                    learnedCompletionCurrentAmps: optionalDoubleValue(powerbank, key: "learnedCompletionCurrentAmps"),
1803
+                    minimumCurrentAmps: optionalDoubleValue(powerbank, key: "minimumCurrentAmps"),
1804
+                    sourceObservedVoltageSelections: derived.voltageMaxCurrents.keys.sorted().isEmpty
1805
+                        ? observedVoltages
1806
+                        : derived.voltageMaxCurrents.keys.sorted(),
1807
+                    sourceVoltageMaxCurrents: derived.voltageMaxCurrents,
1808
+                    sourceIdleCurrentAmps: optionalDoubleValue(powerbank, key: "sourceIdleCurrentAmps"),
1809
+                    sourceMaximumPowerWatts: derived.maxPowerWatts
1810
+                        ?? optionalDoubleValue(powerbank, key: "sourceMaximumPowerWatts"),
1811
+                    sourceEfficiencyFactor: derived.efficiencyFactor
1812
+                        ?? optionalDoubleValue(powerbank, key: "sourceEfficiencyFactor"),
1813
+                    notes: stringValue(powerbank, key: "notes"),
1814
+                    createdAt: dateValue(powerbank, key: "createdAt") ?? Date(),
1815
+                    updatedAt: dateValue(powerbank, key: "updatedAt") ?? Date(),
1816
+                    sessionsAsSubject: subjectSessions,
1817
+                    sessionsAsSource: sourceSessions
1818
+                )
1819
+            }
1820
+        }
1821
+
1822
+        return summaries
1823
+    }
1824
+
1825
+    private func createSessionObject(
1826
+        for chargedDevice: NSManagedObject,
1827
+        charger: NSManagedObject?,
1828
+        snapshot: ChargingMonitorSnapshot,
1829
+        stopThreshold: Double?,
1830
+        chargingTransportMode: ChargingTransportMode,
1831
+        chargingStateMode: ChargingStateMode,
1832
+        autoStopEnabled: Bool
1833
+    ) -> NSManagedObject? {
1834
+        guard
1835
+            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSession, in: context),
1836
+            let chargedDeviceID = stringValue(chargedDevice, key: "id")
1837
+        else {
1838
+            return nil
1839
+        }
1840
+
1841
+        let session = NSManagedObject(entity: entity, insertInto: context)
1842
+        let now = snapshot.observedAt
1843
+        session.setValue(UUID().uuidString, forKey: "id")
1844
+        session.setValue(chargedDeviceID, forKey: "chargedDeviceID")
1845
+        session.setValue(charger.flatMap { stringValue($0, key: "id") }, forKey: "chargerID")
1846
+        session.setValue(snapshot.meterMACAddress, forKey: "meterMACAddress")
1847
+        session.setValue(snapshot.meterName, forKey: "meterName")
1848
+        session.setValue(snapshot.meterModel, forKey: "meterModel")
1849
+        session.setValue(now, forKey: "startedAt")
1850
+        session.setValue(now, forKey: "lastObservedAt")
1851
+        session.setValue(ChargeSessionStatus.active.rawValue, forKey: "statusRawValue")
1852
+        let usesMeterCounters = snapshot.meterEnergyCounterWh != nil || snapshot.meterChargeCounterAh != nil
1853
+        session.setValue(
1854
+            (usesMeterCounters ? ChargeSessionSourceMode.offline : .live).rawValue,
1855
+            forKey: "sourceModeRawValue"
1856
+        )
1857
+        session.setValue(chargingTransportMode.rawValue, forKey: "chargingTransportRawValue")
1858
+        session.setValue(chargingStateMode.rawValue, forKey: "chargingStateRawValue")
1859
+        session.setValue(autoStopEnabled, forKey: "autoStopEnabled")
1860
+        session.setValue(stopThreshold ?? 0, forKey: "stopThresholdAmps")
1861
+        session.setValue(snapshot.voltageVolts, forKey: "selectedSourceVoltageVolts")
1862
+        session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
1863
+        session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
1864
+        session.setValue(
1865
+            chargingTransportMode == .wired ? snapshot.voltageVolts : nil,
1866
+            forKey: "lastObservedVoltageVolts"
1867
+        )
1868
+        session.setValue(
1869
+            hasObservedChargeFlow(
1870
+                currentAmps: snapshot.currentAmps,
1871
+                chargingTransportMode: chargingTransportMode,
1872
+                charger: charger,
1873
+                stopThreshold: stopThreshold
1874
+            ),
1875
+            forKey: "hasObservedChargeFlow"
1876
+        )
1877
+        session.setValue(snapshot.currentAmps, forKey: "minimumObservedCurrentAmps")
1878
+        session.setValue(snapshot.currentAmps, forKey: "maximumObservedCurrentAmps")
1879
+        session.setValue(snapshot.powerWatts, forKey: "maximumObservedPowerWatts")
1880
+        session.setValue(
1881
+            chargingTransportMode == .wired ? snapshot.voltageVolts : nil,
1882
+            forKey: "maximumObservedVoltageVolts"
1883
+        )
1884
+        session.setValue(boolValue(chargedDevice, key: "supportsChargingWhileOff"), forKey: "supportsChargingWhileOff")
1885
+        if let selectedDataGroup = snapshot.selectedDataGroup {
1886
+            session.setValue(Int16(selectedDataGroup), forKey: "selectedDataGroup")
1887
+        }
1888
+        if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
1889
+            session.setValue(meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
1890
+            session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh")
1891
+        }
1892
+        if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
1893
+            session.setValue(meterChargeCounterAh, forKey: "meterChargeBaselineAh")
1894
+            session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
1895
+        }
1896
+        if let meterRecordingDurationSeconds = snapshot.meterRecordingDurationSeconds {
1897
+            setValue(meterRecordingDurationSeconds, on: session, key: "meterDurationBaselineSeconds")
1898
+            setValue(meterRecordingDurationSeconds, on: session, key: "meterLastDurationSeconds")
1899
+        }
1900
+        session.setValue(now, forKey: "createdAt")
1901
+        session.setValue(now, forKey: "updatedAt")
1902
+
1903
+        return session
1272 1904
     }
1273 1905
 
1274
-    private func createSessionObject(
1275
-        for chargedDevice: NSManagedObject,
1276
-        charger: NSManagedObject?,
1906
+    private func createPowerbankSubjectSessionObject(
1907
+        for powerbank: NSManagedObject,
1277 1908
         snapshot: ChargingMonitorSnapshot,
1278 1909
         stopThreshold: Double?,
1279
-        chargingTransportMode: ChargingTransportMode,
1280
-        chargingStateMode: ChargingStateMode,
1281 1910
         autoStopEnabled: Bool
1282 1911
     ) -> NSManagedObject? {
1283 1912
         guard
1284 1913
             let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSession, in: context),
1285
-            let chargedDeviceID = stringValue(chargedDevice, key: "id")
1914
+            let powerbankID = stringValue(powerbank, key: "id")
1286 1915
         else {
1287 1916
             return nil
1288 1917
         }
@@ -1290,8 +1919,9 @@ final class ChargeInsightsStore {
1290 1919
         let session = NSManagedObject(entity: entity, insertInto: context)
1291 1920
         let now = snapshot.observedAt
1292 1921
         session.setValue(UUID().uuidString, forKey: "id")
1293
-        session.setValue(chargedDeviceID, forKey: "chargedDeviceID")
1294
-        session.setValue(charger.flatMap { stringValue($0, key: "id") }, forKey: "chargerID")
1922
+        session.setValue(powerbankID, forKey: "chargedDeviceID")
1923
+        session.setValue(powerbankID, forKey: "chargedPowerbankID")
1924
+        session.setValue(nil, forKey: "chargerID")
1295 1925
         session.setValue(snapshot.meterMACAddress, forKey: "meterMACAddress")
1296 1926
         session.setValue(snapshot.meterName, forKey: "meterName")
1297 1927
         session.setValue(snapshot.meterModel, forKey: "meterModel")
@@ -1303,22 +1933,19 @@ final class ChargeInsightsStore {
1303 1933
             (usesMeterCounters ? ChargeSessionSourceMode.offline : .live).rawValue,
1304 1934
             forKey: "sourceModeRawValue"
1305 1935
         )
1306
-        session.setValue(chargingTransportMode.rawValue, forKey: "chargingTransportRawValue")
1307
-        session.setValue(chargingStateMode.rawValue, forKey: "chargingStateRawValue")
1936
+        session.setValue(ChargingTransportMode.wired.rawValue, forKey: "chargingTransportRawValue")
1937
+        session.setValue(ChargingStateMode.on.rawValue, forKey: "chargingStateRawValue")
1308 1938
         session.setValue(autoStopEnabled, forKey: "autoStopEnabled")
1309 1939
         session.setValue(stopThreshold ?? 0, forKey: "stopThresholdAmps")
1310 1940
         session.setValue(snapshot.voltageVolts, forKey: "selectedSourceVoltageVolts")
1311 1941
         session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
1312 1942
         session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
1313
-        session.setValue(
1314
-            chargingTransportMode == .wired ? snapshot.voltageVolts : nil,
1315
-            forKey: "lastObservedVoltageVolts"
1316
-        )
1943
+        session.setValue(snapshot.voltageVolts, forKey: "lastObservedVoltageVolts")
1317 1944
         session.setValue(
1318 1945
             hasObservedChargeFlow(
1319 1946
                 currentAmps: snapshot.currentAmps,
1320
-                chargingTransportMode: chargingTransportMode,
1321
-                charger: charger,
1947
+                chargingTransportMode: .wired,
1948
+                charger: nil,
1322 1949
                 stopThreshold: stopThreshold
1323 1950
             ),
1324 1951
             forKey: "hasObservedChargeFlow"
@@ -1326,11 +1953,8 @@ final class ChargeInsightsStore {
1326 1953
         session.setValue(snapshot.currentAmps, forKey: "minimumObservedCurrentAmps")
1327 1954
         session.setValue(snapshot.currentAmps, forKey: "maximumObservedCurrentAmps")
1328 1955
         session.setValue(snapshot.powerWatts, forKey: "maximumObservedPowerWatts")
1329
-        session.setValue(
1330
-            chargingTransportMode == .wired ? snapshot.voltageVolts : nil,
1331
-            forKey: "maximumObservedVoltageVolts"
1332
-        )
1333
-        session.setValue(boolValue(chargedDevice, key: "supportsChargingWhileOff"), forKey: "supportsChargingWhileOff")
1956
+        session.setValue(snapshot.voltageVolts, forKey: "maximumObservedVoltageVolts")
1957
+        session.setValue(false, forKey: "supportsChargingWhileOff")
1334 1958
         if let selectedDataGroup = snapshot.selectedDataGroup {
1335 1959
             session.setValue(Int16(selectedDataGroup), forKey: "selectedDataGroup")
1336 1960
         }
@@ -1349,8 +1973,6 @@ final class ChargeInsightsStore {
1349 1973
         session.setValue(now, forKey: "createdAt")
1350 1974
         session.setValue(now, forKey: "updatedAt")
1351 1975
 
1352
-        chargedDevice.setValue(snapshot.meterMACAddress, forKey: "lastAssociatedMeterMAC")
1353
-        chargedDevice.setValue(now, forKey: "updatedAt")
1354 1976
         return session
1355 1977
     }
1356 1978
 
@@ -1583,6 +2205,7 @@ final class ChargeInsightsStore {
1583 2205
         )
1584 2206
         sample.setValue(doubleValue(session, key: "measuredEnergyWh"), forKey: "measuredEnergyWh")
1585 2207
         sample.setValue(doubleValue(session, key: "measuredChargeAh"), forKey: "measuredChargeAh")
2208
+        setValue(predictedBatteryPercent(for: session), on: sample, key: "estimatedBatteryPercent")
1586 2209
         sample.setValue(Int16(updatedCount), forKey: "sampleCount")
1587 2210
         sample.setValue(dateValue(sample, key: "createdAt") ?? snapshot.observedAt, forKey: "createdAt")
1588 2211
         sample.setValue(snapshot.observedAt, forKey: "updatedAt")
@@ -1778,6 +2401,7 @@ final class ChargeInsightsStore {
1778 2401
                 percent: finalBatteryPercent,
1779 2402
                 flag: .final,
1780 2403
                 timestamp: observedAt,
2404
+                subject: stringValue(session, key: "chargedPowerbankID") == nil ? .chargedDevice : .powerbank,
1781 2405
                 to: session
1782 2406
             )
1783 2407
         }
@@ -1791,6 +2415,7 @@ final class ChargeInsightsStore {
1791 2415
         clearCompletionConfirmationState(for: session)
1792 2416
         session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1793 2417
         updateCapacityEstimate(for: session)
2418
+        refreshEstimatedBatteryPercents(for: session)
1794 2419
         session.setValue(observedAt, forKey: "updatedAt")
1795 2420
 
1796 2421
         if status == .completed {
@@ -1802,51 +2427,78 @@ final class ChargeInsightsStore {
1802 2427
         }
1803 2428
     }
1804 2429
 
1805
-    private func predictedBatteryPercent(for session: NSManagedObject) -> Double? {
2430
+    private func predictedBatteryPercent(
2431
+        for session: NSManagedObject,
2432
+        effectiveEnergyWhOverride: Double? = nil,
2433
+        referenceTimestamp: Date? = nil
2434
+    ) -> Double? {
1806 2435
         guard
1807 2436
             let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1808
-            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID),
1809
-            let estimatedCapacityWh = resolvedEstimatedBatteryCapacityWh(for: session, chargedDevice: chargedDevice),
1810
-            estimatedCapacityWh > 0
2437
+            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID)
1811 2438
         else {
1812 2439
             return nil
1813 2440
         }
1814 2441
 
1815
-        // Compute effective battery energy dynamically so the prediction uses the
1816
-        // most current measuredEnergyWh rather than the stale effectiveBatteryEnergyWh
1817
-        // (which is only refreshed at session start, checkpoint insertion, and finish).
1818
-        let rawMeasuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1819
-        let measuredEnergyWh: Double
1820
-        switch chargingTransportMode(for: session) {
1821
-        case .wired:
1822
-            measuredEnergyWh = rawMeasuredEnergyWh
1823
-        case .wireless:
1824
-            if let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 {
1825
-                measuredEnergyWh = rawMeasuredEnergyWh * factor
1826
-            } else {
1827
-                measuredEnergyWh = optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
1828
-                    ?? rawMeasuredEnergyWh
2442
+        let estimatedCapacityWh = resolvedEstimatedBatteryCapacityWh(
2443
+            for: session,
2444
+            chargedDevice: chargedDevice
2445
+        )
2446
+        let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other
2447
+        let canUseCheckpointEnergyMap = deviceClass == .watch || deviceClass == .iphone
2448
+        let measuredEnergyWh = effectiveEnergyWhOverride
2449
+            ?? effectiveBatteryEnergyWh(
2450
+                rawMeasuredEnergyWh: doubleValue(session, key: "measuredEnergyWh"),
2451
+                for: session
2452
+            )
2453
+        let sessionID = stringValue(session, key: "id") ?? ""
2454
+
2455
+        func anchorCapacityCandidates(from anchors: [BatteryLevelPredictionAnchor]) -> [Double] {
2456
+            var candidates: [Double] = []
2457
+
2458
+            for lowerIndex in anchors.indices {
2459
+                for upperIndex in anchors.indices where upperIndex > lowerIndex {
2460
+                    let lower = anchors[lowerIndex]
2461
+                    let upper = anchors[upperIndex]
2462
+                    let percentDelta = upper.percent - lower.percent
2463
+                    let energyDelta = upper.energyWh - lower.energyWh
2464
+
2465
+                    guard percentDelta >= 3, energyDelta > 0.01 else {
2466
+                        continue
2467
+                    }
2468
+
2469
+                    let capacityWh = energyDelta / (percentDelta / 100)
2470
+                    guard capacityWh.isFinite, capacityWh > 0, capacityWh < 1_000 else {
2471
+                        continue
2472
+                    }
2473
+
2474
+                    candidates.append(capacityWh)
2475
+                }
1829 2476
             }
2477
+
2478
+            return candidates
1830 2479
         }
1831
-        let sessionID = stringValue(session, key: "id") ?? ""
1832 2480
 
1833
-        struct Anchor {
1834
-            let percent: Double
1835
-            let energyWh: Double
1836
-            let timestamp: Date
1837
-            let isCheckpoint: Bool
2481
+        func inferredCheckpointEnergyMapCapacityWh(from anchors: [BatteryLevelPredictionAnchor]) -> Double? {
2482
+            let candidates = anchorCapacityCandidates(from: anchors)
2483
+            guard !candidates.isEmpty else {
2484
+                return nil
2485
+            }
2486
+
2487
+            let sortedCandidates = candidates.sorted()
2488
+            return sortedCandidates[sortedCandidates.count / 2]
1838 2489
         }
1839 2490
 
1840
-        var anchors: [Anchor] = []
2491
+        var anchors: [BatteryLevelPredictionAnchor] = []
1841 2492
         if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1842 2493
            startBatteryPercent >= 0 {
1843 2494
             anchors.append(
1844
-                Anchor(
2495
+                BatteryLevelPredictionAnchor(
1845 2496
                     percent: startBatteryPercent,
1846 2497
                     energyWh: 0,
1847 2498
                     timestamp: dateValue(session, key: "trimStart")
1848 2499
                         ?? dateValue(session, key: "startedAt")
1849 2500
                         ?? Date.distantPast,
2501
+                    description: "session start",
1850 2502
                     isCheckpoint: false
1851 2503
                 )
1852 2504
             )
@@ -1862,31 +2514,154 @@ final class ChargeInsightsStore {
1862 2514
             }
1863 2515
             .filter { $0.batteryPercent >= 0 }
1864 2516
             .map {
1865
-                Anchor(
2517
+                BatteryLevelPredictionAnchor(
1866 2518
                     percent: $0.batteryPercent,
1867 2519
                     energyWh: $0.measuredEnergyWh,
1868 2520
                     timestamp: $0.timestamp,
2521
+                    description: $0.flag.anchorDescription,
1869 2522
                     isCheckpoint: true
1870 2523
                 )
1871 2524
             }
1872 2525
         anchors.append(contentsOf: checkpointAnchors)
1873 2526
 
1874
-        guard !anchors.isEmpty else {
2527
+        if optionalDoubleValue(session, key: "startBatteryPercent") == unresolvedFlatBatteryPercent {
2528
+            if let virtualZeroEnergyWh = BatteryLevelPredictionTuning.inferredVirtualZeroEnergyWh(
2529
+                from: anchors,
2530
+                estimatedCapacityWh: estimatedCapacityWh,
2531
+                historicalReserveEnergyWh: estimatedFlatReserveEnergyWh(
2532
+                    forChargedDeviceID: chargedDeviceID,
2533
+                    excludingSessionID: sessionID
2534
+                )
2535
+            ) {
2536
+                anchors.append(
2537
+                    BatteryLevelPredictionAnchor(
2538
+                        percent: 0,
2539
+                        energyWh: virtualZeroEnergyWh,
2540
+                        timestamp: dateValue(session, key: "trimStart")
2541
+                            ?? dateValue(session, key: "startedAt")
2542
+                            ?? Date.distantPast,
2543
+                        description: "estimated flat reserve",
2544
+                        isCheckpoint: false
2545
+                    )
2546
+                )
2547
+            } else if let firstCheckpoint = anchors.min(by: { $0.energyWh < $1.energyWh }),
2548
+                      measuredEnergyWh < firstCheckpoint.energyWh - 0.05 {
2549
+                return nil
2550
+            }
2551
+        }
2552
+
2553
+        let sortedAnchors = anchors.sorted { lhs, rhs in
2554
+            if lhs.energyWh != rhs.energyWh {
2555
+                return lhs.energyWh < rhs.energyWh
2556
+            }
2557
+            return lhs.timestamp < rhs.timestamp
2558
+        }
2559
+
2560
+        guard !sortedAnchors.isEmpty else {
1875 2561
             return optionalDoubleValue(session, key: "endBatteryPercent")
1876 2562
         }
1877 2563
 
1878
-        let anchor = anchors.filter { $0.energyWh <= measuredEnergyWh + 0.05 }.last ?? anchors.first!
2564
+        let inferredCapacityWh = estimatedCapacityWh
2565
+            ?? (canUseCheckpointEnergyMap ? inferredCheckpointEnergyMapCapacityWh(from: sortedAnchors) : nil)
2566
+        let lowerAnchor = sortedAnchors.last { $0.energyWh <= measuredEnergyWh + 0.05 }
2567
+        let upperAnchor = sortedAnchors.first { $0.energyWh > measuredEnergyWh + 0.05 }
2568
+        let anchor = lowerAnchor ?? upperAnchor ?? sortedAnchors.first!
2569
+
2570
+        if let lowerAnchor,
2571
+           let upperAnchor,
2572
+           upperAnchor.energyWh > lowerAnchor.energyWh + 0.05 {
2573
+            let interpolationProgress = min(
2574
+                max(
2575
+                    (measuredEnergyWh - lowerAnchor.energyWh) /
2576
+                    (upperAnchor.energyWh - lowerAnchor.energyWh),
2577
+                    0
2578
+                ),
2579
+                1
2580
+            )
2581
+            return min(
2582
+                max(
2583
+                    lowerAnchor.percent +
2584
+                    (upperAnchor.percent - lowerAnchor.percent) * interpolationProgress,
2585
+                    0
2586
+                ),
2587
+                100
2588
+            )
2589
+        }
2590
+
2591
+        if let chargeCurve = typicalChargeCurve(
2592
+            forChargedDeviceID: chargedDeviceID,
2593
+            excludingSessionID: sessionID
2594
+        ),
2595
+           let curvePredictedPercent = BatteryLevelPredictionTuning.predictedPercent(
2596
+            anchorPercent: anchor.percent,
2597
+            anchorEnergyWh: anchor.energyWh,
2598
+            effectiveEnergyWh: measuredEnergyWh,
2599
+            chargeCurve: chargeCurve,
2600
+            deviationFactor: BatteryLevelPredictionTuning.deviationFactor(
2601
+                anchors: sortedAnchors,
2602
+                chargeCurve: chargeCurve
2603
+            )
2604
+           ) {
2605
+            return curvePredictedPercent
2606
+        }
2607
+
2608
+        guard let inferredCapacityWh, inferredCapacityWh > 0 else {
2609
+            return nil
2610
+        }
2611
+
1879 2612
         return BatteryLevelPredictionTuning.predictedPercent(
1880 2613
             anchorPercent: anchor.percent,
1881 2614
             anchorEnergyWh: anchor.energyWh,
1882 2615
             anchorTimestamp: anchor.timestamp,
1883 2616
             anchorIsCheckpoint: anchor.isCheckpoint,
1884 2617
             effectiveEnergyWh: measuredEnergyWh,
1885
-            referenceTimestamp: dateValue(session, key: "lastObservedAt") ?? anchor.timestamp,
1886
-            estimatedCapacityWh: estimatedCapacityWh
2618
+            referenceTimestamp: referenceTimestamp
2619
+                ?? dateValue(session, key: "lastObservedAt")
2620
+                ?? anchor.timestamp,
2621
+            estimatedCapacityWh: inferredCapacityWh
1887 2622
         )
1888 2623
     }
1889 2624
 
2625
+    private func effectiveBatteryEnergyWh(
2626
+        rawMeasuredEnergyWh: Double,
2627
+        for session: NSManagedObject
2628
+    ) -> Double {
2629
+        switch chargingTransportMode(for: session) {
2630
+        case .wired:
2631
+            return rawMeasuredEnergyWh
2632
+        case .wireless:
2633
+            if let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 {
2634
+                return rawMeasuredEnergyWh * factor
2635
+            }
2636
+            let sessionMeasuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
2637
+            if let sessionEffectiveEnergyWh = optionalDoubleValue(session, key: "effectiveBatteryEnergyWh"),
2638
+               sessionMeasuredEnergyWh > 0 {
2639
+                return rawMeasuredEnergyWh * (sessionEffectiveEnergyWh / sessionMeasuredEnergyWh)
2640
+            }
2641
+            return rawMeasuredEnergyWh
2642
+        }
2643
+    }
2644
+
2645
+    private func refreshEstimatedBatteryPercents(for session: NSManagedObject) {
2646
+        guard let sessionID = stringValue(session, key: "id") else {
2647
+            return
2648
+        }
2649
+
2650
+        for sample in fetchSessionSampleObjects(forSessionID: sessionID) {
2651
+            let effectiveEnergyWh = effectiveBatteryEnergyWh(
2652
+                rawMeasuredEnergyWh: doubleValue(sample, key: "measuredEnergyWh"),
2653
+                for: session
2654
+            )
2655
+            let percent = predictedBatteryPercent(
2656
+                for: session,
2657
+                effectiveEnergyWhOverride: effectiveEnergyWh,
2658
+                referenceTimestamp: dateValue(sample, key: "timestamp")
2659
+            )
2660
+            setValue(percent, on: sample, key: "estimatedBatteryPercent")
2661
+            setValue(Date(), on: sample, key: "updatedAt")
2662
+        }
2663
+    }
2664
+
1890 2665
     private func resolvedEstimatedBatteryCapacityWh(
1891 2666
         for session: NSManagedObject,
1892 2667
         chargedDevice: NSManagedObject
@@ -2027,7 +2802,8 @@ final class ChargeInsightsStore {
2027 2802
         flag: ChargeCheckpointFlag,
2028 2803
         timestamp: Date = Date(),
2029 2804
         measuredEnergyWhOverride: Double? = nil,
2030
-        measuredChargeAhOverride: Double? = nil,
2805
+        subject: CheckpointSubject = .chargedDevice,
2806
+        barsValue: Int = 0,
2031 2807
         to session: NSManagedObject
2032 2808
     ) -> String? {
2033 2809
         guard
@@ -2042,15 +2818,24 @@ final class ChargeInsightsStore {
2042 2818
         let checkpointEnergyWh = measuredEnergyWhOverride
2043 2819
             ?? optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
2044 2820
             ?? doubleValue(session, key: "measuredEnergyWh")
2045
-        let checkpointChargeAh = measuredChargeAhOverride
2046
-            ?? doubleValue(session, key: "measuredChargeAh")
2047 2821
         checkpoint.setValue(UUID().uuidString, forKey: "id")
2048 2822
         checkpoint.setValue(sessionID, forKey: "sessionID")
2049
-        checkpoint.setValue(chargedDeviceID, forKey: "chargedDeviceID")
2823
+        switch subject {
2824
+        case .chargedDevice:
2825
+            checkpoint.setValue(chargedDeviceID, forKey: "chargedDeviceID")
2826
+            checkpoint.setValue(nil, forKey: "powerbankID")
2827
+        case .powerbank:
2828
+            // Link to the charged powerbank when it is the session subject, otherwise
2829
+            // to the source powerbank being monitored alongside a device session.
2830
+            let powerbankID = stringValue(session, key: "chargedPowerbankID")
2831
+                ?? stringValue(session, key: "sourcePowerbankID")
2832
+            checkpoint.setValue(chargedDeviceID, forKey: "chargedDeviceID")
2833
+            checkpoint.setValue(powerbankID, forKey: "powerbankID")
2834
+        }
2835
+        checkpoint.setValue(Int16(max(0, barsValue)), forKey: "batteryBarsValue")
2050 2836
         checkpoint.setValue(timestamp, forKey: "timestamp")
2051 2837
         checkpoint.setValue(percent, forKey: "batteryPercent")
2052 2838
         checkpoint.setValue(checkpointEnergyWh, forKey: "measuredEnergyWh")
2053
-        checkpoint.setValue(checkpointChargeAh, forKey: "measuredChargeAh")
2054 2839
         checkpoint.setValue(doubleValue(session, key: "lastObservedCurrentAmps"), forKey: "currentAmps")
2055 2840
         checkpoint.setValue(
2056 2841
             chargingTransportMode(for: session) == .wired ? optionalDoubleValue(session, key: "lastObservedVoltageVolts") : nil,
@@ -2059,15 +2844,19 @@ final class ChargeInsightsStore {
2059 2844
         checkpoint.setValue(flag.rawValue, forKey: "label")
2060 2845
         checkpoint.setValue(timestamp, forKey: "createdAt")
2061 2846
 
2062
-        let existingStartBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent")
2063
-        if existingStartBatteryPercent == nil || ((existingStartBatteryPercent ?? 0) < 0 && percent > 0) {
2064
-            session.setValue(percent, forKey: "startBatteryPercent")
2065
-        }
2066
-        if (existingStartBatteryPercent ?? 0) >= 0 || percent > 0 {
2067
-            session.setValue(percent, forKey: "endBatteryPercent")
2847
+        let tracksSessionSubject = subject == .chargedDevice || stringValue(session, key: "chargedPowerbankID") != nil
2848
+        if tracksSessionSubject {
2849
+            let existingStartBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent")
2850
+            if existingStartBatteryPercent == nil {
2851
+                session.setValue(percent, forKey: "startBatteryPercent")
2852
+            }
2853
+            if (existingStartBatteryPercent ?? 0) >= 0 || percent > 0 {
2854
+                session.setValue(percent, forKey: "endBatteryPercent")
2855
+            }
2068 2856
         }
2069 2857
         session.setValue(timestamp, forKey: "updatedAt")
2070 2858
         updateCapacityEstimate(for: session)
2859
+        refreshEstimatedBatteryPercents(for: session)
2071 2860
 
2072 2861
         return chargedDeviceID
2073 2862
     }
@@ -2089,30 +2878,30 @@ final class ChargeInsightsStore {
2089 2878
 
2090 2879
         session.setValue(Date(), forKey: "updatedAt")
2091 2880
         updateCapacityEstimate(for: session)
2881
+        refreshEstimatedBatteryPercents(for: session)
2092 2882
     }
2093 2883
 
2094 2884
     @discardableResult
2095 2885
     private func addBatteryCheckpoint(
2096 2886
         percent: Double,
2097 2887
         measuredEnergyWh: Double? = nil,
2098
-        measuredChargeAh: Double? = nil,
2099 2888
         flag: ChargeCheckpointFlag,
2889
+        subject: CheckpointSubject = .chargedDevice,
2890
+        barsValue: Int = 0,
2100 2891
         to session: NSManagedObject,
2101 2892
         timestamp: Date = Date()
2102 2893
     ) -> Bool {
2103 2894
         if let measuredEnergyWh, measuredEnergyWh.isFinite {
2104 2895
             session.setValue(max(measuredEnergyWh, 0), forKey: "measuredEnergyWh")
2105 2896
         }
2106
-        if let measuredChargeAh, measuredChargeAh.isFinite {
2107
-            session.setValue(max(measuredChargeAh, 0), forKey: "measuredChargeAh")
2108
-        }
2109 2897
 
2110 2898
         guard let chargedDeviceID = insertBatteryCheckpoint(
2111 2899
             percent: percent,
2112 2900
             flag: flag,
2113 2901
             timestamp: timestamp,
2114 2902
             measuredEnergyWhOverride: measuredEnergyWh,
2115
-            measuredChargeAhOverride: measuredChargeAh,
2903
+            subject: subject,
2904
+            barsValue: barsValue,
2116 2905
             to: session
2117 2906
         ) else {
2118 2907
             return false
@@ -2122,7 +2911,12 @@ final class ChargeInsightsStore {
2122 2911
             return false
2123 2912
         }
2124 2913
 
2125
-        refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
2914
+        // Device-subject checkpoints feed device-side capacity learning. Powerbank-subject
2915
+        // checkpoints feed powerbank-side derivation, which is computed at materialization time
2916
+        // (see PowerbankSummary fetch path).
2917
+        if subject == .chargedDevice {
2918
+            refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
2919
+        }
2126 2920
         return saveContext()
2127 2921
     }
2128 2922
 
@@ -2283,9 +3077,76 @@ final class ChargeInsightsStore {
2283 3077
             .sorted { $0.timestamp < $1.timestamp }
2284 3078
     }
2285 3079
 
3080
+    private func typicalChargeCurve(
3081
+        forChargedDeviceID chargedDeviceID: String,
3082
+        excludingSessionID excludedSessionID: String? = nil
3083
+    ) -> BatteryChargeCurve? {
3084
+        let sessionObjects = fetchSessions(forChargedDeviceID: chargedDeviceID)
3085
+            .filter {
3086
+                statusValue($0, key: "statusRawValue") == .completed
3087
+            }
3088
+
3089
+        let sessionSummaries = sessionObjects.compactMap { session -> ChargeSessionSummary? in
3090
+            guard let sessionID = stringValue(session, key: "id"),
3091
+                  sessionID != excludedSessionID else {
3092
+                return nil
3093
+            }
3094
+
3095
+            return makeSessionSummary(
3096
+                from: session,
3097
+                checkpoints: fetchCheckpointObjects(forSessionID: sessionID),
3098
+                samples: []
3099
+            )
3100
+        }
3101
+
3102
+        return BatteryChargeCurve(
3103
+            typicalCurvePoints: buildTypicalCurve(from: sessionSummaries)
3104
+        )
3105
+    }
3106
+
3107
+    private func estimatedFlatReserveEnergyWh(
3108
+        forChargedDeviceID chargedDeviceID: String,
3109
+        excludingSessionID excludedSessionID: String? = nil
3110
+    ) -> Double? {
3111
+        let reserves = fetchSessions(forChargedDeviceID: chargedDeviceID)
3112
+            .filter {
3113
+                statusValue($0, key: "statusRawValue") == .completed
3114
+                    && optionalDoubleValue($0, key: "startBatteryPercent") == unresolvedFlatBatteryPercent
3115
+                    && stringValue($0, key: "id") != excludedSessionID
3116
+            }
3117
+            .compactMap { session -> Double? in
3118
+                guard let sessionID = stringValue(session, key: "id") else {
3119
+                    return nil
3120
+                }
3121
+
3122
+                let anchors = fetchCheckpointObjects(forSessionID: sessionID)
3123
+                    .compactMap(makeCheckpointSummary(from:))
3124
+                    .map {
3125
+                        BatteryLevelPredictionAnchor(
3126
+                            percent: $0.batteryPercent,
3127
+                            energyWh: $0.measuredEnergyWh,
3128
+                            timestamp: $0.timestamp,
3129
+                            description: $0.flag.anchorDescription,
3130
+                            isCheckpoint: true
3131
+                        )
3132
+                    }
3133
+
3134
+                return BatteryLevelPredictionTuning.inferredVirtualZeroEnergyWh(
3135
+                    from: anchors,
3136
+                    estimatedCapacityWh: optionalDoubleValue(session, key: "capacityEstimateWh")
3137
+                )
3138
+            }
3139
+
3140
+        guard !reserves.isEmpty else {
3141
+            return nil
3142
+        }
3143
+
3144
+        let sortedReserves = reserves.sorted()
3145
+        return sortedReserves[sortedReserves.count / 2]
3146
+    }
3147
+
2286 3148
     private func buildTypicalCurve(from sessions: [ChargeSessionSummary]) -> [TypicalChargeCurvePoint] {
2287 3149
         var groupedEnergyByBin: [Int: [Double]] = [:]
2288
-        var groupedChargeByBin: [Int: [Double]] = [:]
2289 3150
 
2290 3151
         for session in sessions where session.status == .completed {
2291 3152
             let anchors = normalizedTypicalCurveAnchors(for: session)
@@ -2294,46 +3155,36 @@ final class ChargeInsightsStore {
2294 3155
             }
2295 3156
 
2296 3157
             for percentBin in stride(from: 0, through: 100, by: 10) {
2297
-                guard let interpolatedPoint = interpolatedTypicalCurvePoint(
3158
+                guard let energyWh = interpolatedTypicalCurvePoint(
2298 3159
                     for: Double(percentBin),
2299 3160
                     anchors: anchors
2300 3161
                 ) else {
2301 3162
                     continue
2302 3163
                 }
2303 3164
 
2304
-                groupedEnergyByBin[percentBin, default: []].append(interpolatedPoint.energyWh)
2305
-                groupedChargeByBin[percentBin, default: []].append(interpolatedPoint.chargeAh)
3165
+                groupedEnergyByBin[percentBin, default: []].append(energyWh)
2306 3166
             }
2307 3167
         }
2308 3168
 
2309 3169
         let averagedPoints = groupedEnergyByBin.keys.sorted().compactMap { percentBin -> TypicalChargeCurvePoint? in
2310
-            guard
2311
-                let energies = groupedEnergyByBin[percentBin],
2312
-                let charges = groupedChargeByBin[percentBin],
2313
-                !energies.isEmpty,
2314
-                !charges.isEmpty
2315
-            else {
3170
+            guard let energies = groupedEnergyByBin[percentBin], !energies.isEmpty else {
2316 3171
                 return nil
2317 3172
             }
2318 3173
 
2319 3174
             return TypicalChargeCurvePoint(
2320 3175
                 percentBin: percentBin,
2321 3176
                 averageEnergyWh: energies.reduce(0, +) / Double(energies.count),
2322
-                averageChargeAh: charges.reduce(0, +) / Double(charges.count),
2323
-                sampleCount: min(energies.count, charges.count)
3177
+                sampleCount: energies.count
2324 3178
             )
2325 3179
         }
2326 3180
 
2327 3181
         var runningMaximumEnergyWh = 0.0
2328
-        var runningMaximumChargeAh = 0.0
2329 3182
 
2330 3183
         return averagedPoints.map { point in
2331 3184
             runningMaximumEnergyWh = max(runningMaximumEnergyWh, point.averageEnergyWh)
2332
-            runningMaximumChargeAh = max(runningMaximumChargeAh, point.averageChargeAh)
2333 3185
             return TypicalChargeCurvePoint(
2334 3186
                 percentBin: point.percentBin,
2335 3187
                 averageEnergyWh: runningMaximumEnergyWh,
2336
-                averageChargeAh: runningMaximumChargeAh,
2337 3188
                 sampleCount: point.sampleCount
2338 3189
             )
2339 3190
         }
@@ -2341,29 +3192,25 @@ final class ChargeInsightsStore {
2341 3192
 
2342 3193
     private func normalizedTypicalCurveAnchors(
2343 3194
         for session: ChargeSessionSummary
2344
-    ) -> [(percent: Double, energyWh: Double, chargeAh: Double)] {
3195
+    ) -> [(percent: Double, energyWh: Double)] {
2345 3196
         struct Anchor {
2346 3197
             let percent: Double
2347 3198
             let energyWh: Double
2348
-            let chargeAh: Double
2349 3199
             let timestamp: Date
2350 3200
         }
2351 3201
 
2352 3202
         var anchors: [Anchor] = session.checkpoints.compactMap { checkpoint in
2353 3203
             guard checkpoint.batteryPercent.isFinite,
2354 3204
                   checkpoint.measuredEnergyWh.isFinite,
2355
-                  checkpoint.measuredChargeAh.isFinite,
2356 3205
                   checkpoint.batteryPercent >= 0,
2357 3206
                   checkpoint.batteryPercent <= 100,
2358
-                  checkpoint.measuredEnergyWh >= 0,
2359
-                  checkpoint.measuredChargeAh >= 0 else {
3207
+                  checkpoint.measuredEnergyWh >= 0 else {
2360 3208
                 return nil
2361 3209
             }
2362 3210
 
2363 3211
             return Anchor(
2364 3212
                 percent: checkpoint.batteryPercent,
2365 3213
                 energyWh: checkpoint.measuredEnergyWh,
2366
-                chargeAh: checkpoint.measuredChargeAh,
2367 3214
                 timestamp: checkpoint.timestamp
2368 3215
             )
2369 3216
         }
@@ -2376,7 +3223,6 @@ final class ChargeInsightsStore {
2376 3223
                 Anchor(
2377 3224
                     percent: startBatteryPercent,
2378 3225
                     energyWh: 0,
2379
-                    chargeAh: 0,
2380 3226
                     timestamp: session.startedAt
2381 3227
                 )
2382 3228
             )
@@ -2390,7 +3236,6 @@ final class ChargeInsightsStore {
2390 3236
                 Anchor(
2391 3237
                     percent: endBatteryPercent,
2392 3238
                     energyWh: session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh,
2393
-                    chargeAh: session.measuredChargeAh,
2394 3239
                     timestamp: session.endedAt ?? session.lastObservedAt
2395 3240
                 )
2396 3241
             )
@@ -2406,41 +3251,37 @@ final class ChargeInsightsStore {
2406 3251
             return lhs.timestamp < rhs.timestamp
2407 3252
         }
2408 3253
 
2409
-        var collapsedAnchors: [(percent: Double, energyWh: Double, chargeAh: Double)] = []
3254
+        var collapsedAnchors: [(percent: Double, energyWh: Double)] = []
2410 3255
 
2411 3256
         for anchor in sortedAnchors {
2412 3257
             if let lastIndex = collapsedAnchors.indices.last,
2413 3258
                abs(collapsedAnchors[lastIndex].percent - anchor.percent) < 0.000_1 {
2414 3259
                 collapsedAnchors[lastIndex] = (
2415 3260
                     percent: collapsedAnchors[lastIndex].percent,
2416
-                    energyWh: max(collapsedAnchors[lastIndex].energyWh, anchor.energyWh),
2417
-                    chargeAh: max(collapsedAnchors[lastIndex].chargeAh, anchor.chargeAh)
3261
+                    energyWh: max(collapsedAnchors[lastIndex].energyWh, anchor.energyWh)
2418 3262
                 )
2419 3263
             } else {
2420 3264
                 collapsedAnchors.append(
2421
-                    (percent: anchor.percent, energyWh: anchor.energyWh, chargeAh: anchor.chargeAh)
3265
+                    (percent: anchor.percent, energyWh: anchor.energyWh)
2422 3266
                 )
2423 3267
             }
2424 3268
         }
2425 3269
 
2426 3270
         var runningMaximumEnergyWh = 0.0
2427
-        var runningMaximumChargeAh = 0.0
2428 3271
 
2429 3272
         return collapsedAnchors.map { anchor in
2430 3273
             runningMaximumEnergyWh = max(runningMaximumEnergyWh, anchor.energyWh)
2431
-            runningMaximumChargeAh = max(runningMaximumChargeAh, anchor.chargeAh)
2432 3274
             return (
2433 3275
                 percent: anchor.percent,
2434
-                energyWh: runningMaximumEnergyWh,
2435
-                chargeAh: runningMaximumChargeAh
3276
+                energyWh: runningMaximumEnergyWh
2436 3277
             )
2437 3278
         }
2438 3279
     }
2439 3280
 
2440 3281
     private func interpolatedTypicalCurvePoint(
2441 3282
         for percent: Double,
2442
-        anchors: [(percent: Double, energyWh: Double, chargeAh: Double)]
2443
-    ) -> (energyWh: Double, chargeAh: Double)? {
3283
+        anchors: [(percent: Double, energyWh: Double)]
3284
+    ) -> Double? {
2444 3285
         guard
2445 3286
             let firstAnchor = anchors.first,
2446 3287
             let lastAnchor = anchors.last,
@@ -2451,7 +3292,7 @@ final class ChargeInsightsStore {
2451 3292
         }
2452 3293
 
2453 3294
         if let exactAnchor = anchors.first(where: { abs($0.percent - percent) < 0.000_1 }) {
2454
-            return (energyWh: exactAnchor.energyWh, chargeAh: exactAnchor.chargeAh)
3295
+            return exactAnchor.energyWh
2455 3296
         }
2456 3297
 
2457 3298
         guard let upperIndex = anchors.firstIndex(where: { $0.percent > percent }),
@@ -2467,9 +3308,7 @@ final class ChargeInsightsStore {
2467 3308
         }
2468 3309
 
2469 3310
         let ratio = (percent - lowerAnchor.percent) / span
2470
-        let energyWh = lowerAnchor.energyWh + ((upperAnchor.energyWh - lowerAnchor.energyWh) * ratio)
2471
-        let chargeAh = lowerAnchor.chargeAh + ((upperAnchor.chargeAh - lowerAnchor.chargeAh) * ratio)
2472
-        return (energyWh: energyWh, chargeAh: chargeAh)
3311
+        return lowerAnchor.energyWh + ((upperAnchor.energyWh - lowerAnchor.energyWh) * ratio)
2473 3312
     }
2474 3313
 
2475 3314
     private func makeSessionSummary(
@@ -2503,7 +3342,9 @@ final class ChargeInsightsStore {
2503 3342
         return ChargeSessionSummary(
2504 3343
             id: id,
2505 3344
             chargedDeviceID: chargedDeviceID,
3345
+            chargedPowerbankID: uuidValue(object, key: "chargedPowerbankID"),
2506 3346
             chargerID: uuidValue(object, key: "chargerID"),
3347
+            sourcePowerbankID: uuidValue(object, key: "sourcePowerbankID"),
2507 3348
             meterMACAddress: stringValue(object, key: "meterMACAddress"),
2508 3349
             meterName: stringValue(object, key: "meterName"),
2509 3350
             meterModel: stringValue(object, key: "meterModel"),
@@ -2518,9 +3359,7 @@ final class ChargeInsightsStore {
2518 3359
             autoStopEnabled: boolValue(object, key: "autoStopEnabled"),
2519 3360
             measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2520 3361
             effectiveBatteryEnergyWh: optionalDoubleValue(object, key: "effectiveBatteryEnergyWh"),
2521
-            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
2522 3362
             meterEnergyBaselineWh: optionalDoubleValue(object, key: "meterEnergyBaselineWh"),
2523
-            meterChargeBaselineAh: optionalDoubleValue(object, key: "meterChargeBaselineAh"),
2524 3363
             meterDurationBaselineSeconds: optionalDoubleValue(object, key: "meterDurationBaselineSeconds"),
2525 3364
             meterLastDurationSeconds: optionalDoubleValue(object, key: "meterLastDurationSeconds"),
2526 3365
             minimumObservedCurrentAmps: optionalDoubleValue(object, key: "minimumObservedCurrentAmps"),
@@ -2559,7 +3398,7 @@ final class ChargeInsightsStore {
2559 3398
         guard
2560 3399
             let id = uuidValue(object, key: "id"),
2561 3400
             let sessionID = uuidValue(object, key: "sessionID"),
2562
-            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
3401
+            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID") ?? uuidValue(object, key: "powerbankID"),
2563 3402
             let timestamp = dateValue(object, key: "timestamp")
2564 3403
         else {
2565 3404
             return nil
@@ -2569,10 +3408,11 @@ final class ChargeInsightsStore {
2569 3408
             id: id,
2570 3409
             sessionID: sessionID,
2571 3410
             chargedDeviceID: chargedDeviceID,
3411
+            powerbankID: uuidValue(object, key: "powerbankID"),
3412
+            batteryBarsValue: Int(optionalInt16Value(object, key: "batteryBarsValue") ?? 0),
2572 3413
             timestamp: timestamp,
2573 3414
             batteryPercent: doubleValue(object, key: "batteryPercent"),
2574 3415
             measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2575
-            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
2576 3416
             currentAmps: doubleValue(object, key: "currentAmps"),
2577 3417
             voltageVolts: optionalDoubleValue(object, key: "voltageVolts"),
2578 3418
             label: stringValue(object, key: "label")
@@ -2597,7 +3437,7 @@ final class ChargeInsightsStore {
2597 3437
             averageVoltageVolts: optionalDoubleValue(object, key: "averageVoltageVolts"),
2598 3438
             averagePowerWatts: doubleValue(object, key: "averagePowerWatts"),
2599 3439
             measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2600
-            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
3440
+            estimatedBatteryPercent: optionalDoubleValue(object, key: "estimatedBatteryPercent"),
2601 3441
             sampleCount: Int(optionalInt16Value(object, key: "sampleCount") ?? 0)
2602 3442
         )
2603 3443
     }
@@ -2703,6 +3543,20 @@ final class ChargeInsightsStore {
2703 3543
         return (try? context.fetch(request)) ?? []
2704 3544
     }
2705 3545
 
3546
+    private func fetchSessions(forPowerbankSubjectID powerbankID: String) -> [NSManagedObject] {
3547
+        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
3548
+        request.predicate = NSPredicate(format: "chargedPowerbankID == %@", powerbankID)
3549
+        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
3550
+        return (try? context.fetch(request)) ?? []
3551
+    }
3552
+
3553
+    private func fetchSessions(forPowerbankSourceID powerbankID: String) -> [NSManagedObject] {
3554
+        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
3555
+        request.predicate = NSPredicate(format: "sourcePowerbankID == %@", powerbankID)
3556
+        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
3557
+        return (try? context.fetch(request)) ?? []
3558
+    }
3559
+
2706 3560
     private func sampleBackedSessionIDs(
2707 3561
         devices: [NSManagedObject],
2708 3562
         sessionsByDeviceID: [String: [NSManagedObject]],
@@ -2789,30 +3643,6 @@ final class ChargeInsightsStore {
2789 3643
             }
2790 3644
     }
2791 3645
 
2792
-    private func resolvedDeviceObject(for meterMACAddress: String) -> NSManagedObject? {
2793
-        resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: false)
2794
-    }
2795
-
2796
-    private func resolvedChargerObject(for meterMACAddress: String) -> NSManagedObject? {
2797
-        resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: true)
2798
-    }
2799
-
2800
-    private func resolvedAssignedObject(
2801
-        for meterMACAddress: String,
2802
-        expectsChargerClass: Bool
2803
-    ) -> NSManagedObject? {
2804
-        let normalizedMAC = normalizedMACAddress(meterMACAddress)
2805
-        guard !normalizedMAC.isEmpty else { return nil }
2806
-
2807
-        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
2808
-        request.predicate = NSPredicate(format: "lastAssociatedMeterMAC == %@", normalizedMAC)
2809
-        request.sortDescriptors = [NSSortDescriptor(key: "updatedAt", ascending: false)]
2810
-        let matches = (try? context.fetch(request)) ?? []
2811
-        return matches.first { object in
2812
-            isChargerObject(object) == expectsChargerClass
2813
-        }
2814
-    }
2815
-
2816 3646
     private func isChargerObject(_ object: NSManagedObject) -> Bool {
2817 3647
         ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
2818 3648
     }
@@ -2824,6 +3654,13 @@ final class ChargeInsightsStore {
2824 3654
         return (try? context.fetch(request))?.first
2825 3655
     }
2826 3656
 
3657
+    private func fetchPowerbankObject(id: String) -> NSManagedObject? {
3658
+        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.powerbank)
3659
+        request.predicate = NSPredicate(format: "id == %@", id)
3660
+        request.fetchLimit = 1
3661
+        return (try? context.fetch(request))?.first
3662
+    }
3663
+
2827 3664
     private func fetchObjects(entityName: String) -> [NSManagedObject] {
2828 3665
         let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
2829 3666
         return (try? context.fetch(request)) ?? []
@@ -2900,19 +3737,123 @@ final class ChargeInsightsStore {
2900 3737
         return templateDefinition.id
2901 3738
     }
2902 3739
 
2903
-    private func templateDefinition(for chargedDevice: NSManagedObject) -> ChargedDeviceTemplateDefinition? {
2904
-        guard let templateID = stringValue(chargedDevice, key: "deviceTemplateID"),
2905
-              let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID),
2906
-              templateDefinition.kind == deviceClass(for: chargedDevice).kind else {
3740
+    /// Resolves the active DeviceProfile for a ChargedDevice — catalog first
3741
+    /// (covers all built-in templates and chargers), then DB-backed custom profiles.
3742
+    /// Returns nil only for devices that escaped migration (shouldn't happen post Phase 3).
3743
+    private func resolvedProfileDefinition(for chargedDevice: NSManagedObject) -> DeviceProfileDefinition? {
3744
+        if let profileID = stringValue(chargedDevice, key: "profileID") {
3745
+            if let catalogProfile = DeviceProfileCatalog.shared.profile(id: profileID) {
3746
+                return catalogProfile
3747
+            }
3748
+            if let stored = fetchDeviceProfileObject(id: profileID),
3749
+               let definition = makeProfileDefinition(from: stored) {
3750
+                return definition
3751
+            }
3752
+        }
3753
+        // Pre-migration fallback: try the legacy template ID against the catalog.
3754
+        if let templateID = stringValue(chargedDevice, key: "deviceTemplateID"),
3755
+           let catalogProfile = DeviceProfileCatalog.shared.profile(id: templateID) {
3756
+            return catalogProfile
3757
+        }
3758
+        return nil
3759
+    }
3760
+
3761
+    private func fetchDeviceProfileObject(id: String) -> NSManagedObject? {
3762
+        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.deviceProfile)
3763
+        request.predicate = NSPredicate(format: "id == %@", id)
3764
+        request.fetchLimit = 1
3765
+        return (try? context.fetch(request))?.first
3766
+    }
3767
+
3768
+    private func makeProfileDefinition(from object: NSManagedObject) -> DeviceProfileDefinition? {
3769
+        guard let id = stringValue(object, key: "id"),
3770
+              let categoryRaw = stringValue(object, key: "categoryRawValue"),
3771
+              let category = ProfileCategory(rawValue: categoryRaw) else {
2907 3772
             return nil
2908 3773
         }
2909
-        return templateDefinition
3774
+        let name = stringValue(object, key: "name") ?? id
3775
+        let group = stringValue(object, key: "group") ?? "Custom"
3776
+        let iconName = stringValue(object, key: "iconSymbolName") ?? category.symbolName
3777
+        let iconFallback = stringValue(object, key: "iconFallbackSymbolName")
3778
+        let icon = ChargedDeviceTemplateIcon(type: .systemSymbol, name: iconName, fallbackSystemName: iconFallback)
3779
+        let stateRaw = stringValue(object, key: "capChargingStateAvailabilityRawValue")
3780
+            ?? ChargingStateAvailability.onOrOff.rawValue
3781
+        let stateAvailability = ChargingStateAvailability(rawValue: stateRaw) ?? .onOrOff
3782
+        let allowedWirelessProfiles = DeviceProfileDefinition.decodeWirelessProfilesCSV(
3783
+            stringValue(object, key: "capWirelessProfilesRawValue")
3784
+        )
3785
+        let defaultWirelessProfileRaw = stringValue(object, key: "defaultWirelessChargingProfileRawValue")
3786
+        let defaultWirelessProfile = defaultWirelessProfileRaw.flatMap(WirelessChargingProfile.init(rawValue:))
3787
+
3788
+        return DeviceProfileDefinition(
3789
+            id: id,
3790
+            name: name,
3791
+            group: group,
3792
+            category: category,
3793
+            icon: icon,
3794
+            sortOrder: Int((object.value(forKey: "sortOrder") as? Int32) ?? 1000),
3795
+            capWiredCharging: (object.value(forKey: "capWiredCharging") as? Bool) ?? false,
3796
+            capWirelessCharging: (object.value(forKey: "capWirelessCharging") as? Bool) ?? false,
3797
+            capWirelessProfiles: allowedWirelessProfiles,
3798
+            capChargingStateAvailability: stateAvailability,
3799
+            capHasInternalSubject: (object.value(forKey: "capHasInternalSubject") as? Bool) ?? false,
3800
+            defaultWirelessChargingProfile: defaultWirelessProfile,
3801
+            defaultWiredMinimumCurrentAmps: optionalDoubleValue(object, key: "defaultWiredMinimumCurrentAmps"),
3802
+            defaultWirelessMinimumCurrentAmps: optionalDoubleValue(object, key: "defaultWirelessMinimumCurrentAmps"),
3803
+            defaultWiredEstimatedBatteryCapacityWh: optionalDoubleValue(object, key: "defaultWiredEstimatedBatteryCapacityWh"),
3804
+            defaultWirelessEstimatedBatteryCapacityWh: optionalDoubleValue(object, key: "defaultWirelessEstimatedBatteryCapacityWh")
3805
+        )
3806
+    }
3807
+
3808
+    /// Synthesises a `ChargedDeviceTemplateDefinition` from the active profile so the
3809
+    /// existing UI surfaces (icons, group titles, capability summaries) keep working
3810
+    /// without forking. Catalog profiles return their canonical template; custom
3811
+    /// profiles return a definition derived from the profile's persisted shape.
3812
+    private func templateDefinition(for chargedDevice: NSManagedObject) -> ChargedDeviceTemplateDefinition? {
3813
+        guard let profile = resolvedProfileDefinition(for: chargedDevice) else { return nil }
3814
+
3815
+        let kind = profile.category.kind
3816
+        // Pick a representative wireless profile for the legacy template shape.
3817
+        let wirelessProfile = profile.defaultWirelessChargingProfile
3818
+            ?? profile.capWirelessProfiles.first
3819
+            ?? .genericQi
3820
+
3821
+        return ChargedDeviceTemplateDefinition(
3822
+            id: profile.id,
3823
+            name: profile.name,
3824
+            group: profile.group,
3825
+            kind: kind,
3826
+            deviceClass: legacyClass(for: profile.category),
3827
+            icon: profile.icon,
3828
+            chargingStateAvailability: profile.capChargingStateAvailability,
3829
+            supportsWiredCharging: profile.capWiredCharging,
3830
+            supportsWirelessCharging: profile.capWirelessCharging,
3831
+            wirelessChargingProfile: wirelessProfile,
3832
+            sortOrder: profile.sortOrder
3833
+        )
3834
+    }
3835
+
3836
+    private func legacyClass(for category: ProfileCategory) -> ChargedDeviceClass {
3837
+        switch category {
3838
+        case .phone: return .iphone
3839
+        case .watch: return .watch
3840
+        case .powerbank: return .powerbank
3841
+        case .charger: return .charger
3842
+        case .tablet, .laptop, .audioAccessory, .accessoryCase, .other: return .other
3843
+        }
2910 3844
     }
2911 3845
 
2912 3846
     private func supportsWiredCharging(for chargedDevice: NSManagedObject) -> Bool {
2913 3847
         let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil
2914 3848
             ? true
2915 3849
             : boolValue(chargedDevice, key: "supportsWiredCharging")
3850
+
3851
+        if let profile = resolvedProfileDefinition(for: chargedDevice) {
3852
+            // Profile capability is the upper bound; user opt-out preserved.
3853
+            return persistedWiredCharging && profile.capWiredCharging
3854
+        }
3855
+
3856
+        // Pre-migration fallback: legacy class enforcement.
2916 3857
         let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil
2917 3858
             ? false
2918 3859
             : boolValue(chargedDevice, key: "supportsWirelessCharging")
@@ -2923,12 +3864,17 @@ final class ChargeInsightsStore {
2923 3864
     }
2924 3865
 
2925 3866
     private func supportsWirelessCharging(for chargedDevice: NSManagedObject) -> Bool {
2926
-        let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil
2927
-            ? true
2928
-            : boolValue(chargedDevice, key: "supportsWiredCharging")
2929 3867
         let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil
2930 3868
             ? false
2931 3869
             : boolValue(chargedDevice, key: "supportsWirelessCharging")
3870
+
3871
+        if let profile = resolvedProfileDefinition(for: chargedDevice) {
3872
+            return persistedWirelessCharging && profile.capWirelessCharging
3873
+        }
3874
+
3875
+        let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil
3876
+            ? true
3877
+            : boolValue(chargedDevice, key: "supportsWiredCharging")
2932 3878
         return deviceClass(for: chargedDevice).normalizedChargingSupport(
2933 3879
             supportsWiredCharging: persistedWiredCharging,
2934 3880
             supportsWirelessCharging: persistedWirelessCharging
@@ -2936,6 +3882,10 @@ final class ChargeInsightsStore {
2936 3882
     }
2937 3883
 
2938 3884
     private func chargingStateAvailability(for chargedDevice: NSManagedObject) -> ChargingStateAvailability {
3885
+        if let profile = resolvedProfileDefinition(for: chargedDevice) {
3886
+            return profile.capChargingStateAvailability
3887
+        }
3888
+
2939 3889
         let persistedAvailability = stringValue(chargedDevice, key: "chargingStateAvailabilityRawValue")
2940 3890
             .flatMap(ChargingStateAvailability.init(rawValue:))
2941 3891
             ?? ChargingStateAvailability.fallback(
@@ -3005,11 +3955,21 @@ final class ChargeInsightsStore {
3005 3955
         if let type = chargerType(for: chargedDevice) {
3006 3956
             return type.wirelessChargingProfile
3007 3957
         }
3008
-        guard let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
3009
-              let profile = WirelessChargingProfile(rawValue: rawValue) else {
3010
-            return .genericQi
3958
+        let persisted = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue")
3959
+            .flatMap(WirelessChargingProfile.init(rawValue:))
3960
+
3961
+        if let profile = resolvedProfileDefinition(for: chargedDevice),
3962
+           !profile.capWirelessProfiles.isEmpty {
3963
+            // Persisted wins iff still allowed by capabilities; else fall back to profile default.
3964
+            if let persisted, profile.capWirelessProfiles.contains(persisted) {
3965
+                return persisted
3966
+            }
3967
+            return profile.defaultWirelessChargingProfile
3968
+                ?? profile.capWirelessProfiles.first
3969
+                ?? .genericQi
3011 3970
         }
3012
-        return profile
3971
+
3972
+        return persisted ?? .genericQi
3013 3973
     }
3014 3974
 
3015 3975
     private func resolvedPreferredChargingTransportMode(
@@ -3313,6 +4273,101 @@ final class ChargeInsightsStore {
3313 4273
         .max()
3314 4274
     }
3315 4275
 
4276
+    /// View-time derivation of powerbank metrics from materialized session summaries.
4277
+    /// Mirrors the charger derivation pattern but works on `ChargeSessionSummary` (already
4278
+    /// projected) so we don't need to re-fetch NSManagedObjects.
4279
+    /// - voltage profile: groups source-side sessions by selected voltage palier (rounded
4280
+    ///   to 0.5V) and tracks max current observed at each palier.
4281
+    /// - max power: max across source-side sessions' `maximumObservedPowerWatts`.
4282
+    /// - efficiency: ratio of total Wh delivered (as source) vs total Wh received (as subject).
4283
+    ///   Computed only when both sides have non-trivial energy logged.
4284
+    /// - apparent capacity: sum of source-side delivered Wh between the most recent two
4285
+    ///   powerbank-side checkpoints with sufficient battery percent delta. Best-effort.
4286
+    private func derivedPowerbankMetrics(
4287
+        sessionsAsSubject: [ChargeSessionSummary],
4288
+        sessionsAsSource: [ChargeSessionSummary],
4289
+        reporting: BatteryLevelReporting
4290
+    ) -> (
4291
+        voltageMaxCurrents: [Double: Double],
4292
+        maxPowerWatts: Double?,
4293
+        efficiencyFactor: Double?,
4294
+        apparentCapacityWh: Double?
4295
+    ) {
4296
+        var voltageMaxCurrents: [Double: Double] = [:]
4297
+        var maxPower: Double? = nil
4298
+
4299
+        for session in sessionsAsSource {
4300
+            if let voltage = session.selectedSourceVoltageVolts, voltage > 0 {
4301
+                let palier = (voltage * 2).rounded() / 2  // 0.5V buckets
4302
+                if let maxCurrent = session.maximumObservedCurrentAmps, maxCurrent > 0 {
4303
+                    let prev = voltageMaxCurrents[palier] ?? 0
4304
+                    voltageMaxCurrents[palier] = max(prev, maxCurrent)
4305
+                }
4306
+            }
4307
+            if let power = session.maximumObservedPowerWatts, power > 0 {
4308
+                maxPower = max(maxPower ?? 0, power)
4309
+            }
4310
+        }
4311
+
4312
+        let totalDelivered = sessionsAsSource.reduce(0.0) { $0 + $1.measuredEnergyWh }
4313
+        let totalReceived = sessionsAsSubject.reduce(0.0) { $0 + $1.measuredEnergyWh }
4314
+        let efficiency: Double? = (totalDelivered > 0.5 && totalReceived > 0.5)
4315
+            ? totalDelivered / totalReceived
4316
+            : nil
4317
+
4318
+        // Apparent capacity heuristics depend on what reporting the powerbank supports:
4319
+        //
4320
+        // - `.fullOnly`: the only honest signal is the "full" anchor. We look for two
4321
+        //   consecutive full markers (one before a discharge cycle, one after the next
4322
+        //   recharge) and use the source-side energy delivered between them. Single-LED
4323
+        //   powerbanks naturally produce two 100% datapoints separated by usage.
4324
+        // - `.percent` / `.bars`: pair the most recent powerbank-side checkpoints with a
4325
+        //   meaningful battery delta (≥ 30%) and sum source-side energy in that window.
4326
+        // - `.none`: no powerbank checkpoints exist, so apparent capacity stays nil here
4327
+        //   and would have to be inferred differently (currently not attempted).
4328
+        var apparentCapacity: Double? = nil
4329
+
4330
+        let powerbankCheckpoints = (sessionsAsSource + sessionsAsSubject)
4331
+            .flatMap { $0.checkpoints.filter { $0.subject == .powerbank } }
4332
+            .sorted { $0.timestamp < $1.timestamp }
4333
+
4334
+        switch reporting {
4335
+        case .fullOnly:
4336
+            let fullMarkers = powerbankCheckpoints.filter { $0.batteryPercent >= 99 }
4337
+            if let lastFull = fullMarkers.last,
4338
+               let prevFull = fullMarkers.dropLast().last {
4339
+                let lower = prevFull.timestamp
4340
+                let upper = lastFull.timestamp
4341
+                let energyDelivered = sessionsAsSource
4342
+                    .filter { $0.startedAt <= upper && ($0.endedAt ?? $0.lastObservedAt) >= lower }
4343
+                    .reduce(0.0) { $0 + $1.measuredEnergyWh }
4344
+                if energyDelivered > 0.1 {
4345
+                    apparentCapacity = energyDelivered
4346
+                }
4347
+            }
4348
+        case .percent, .bars:
4349
+            if powerbankCheckpoints.count >= 2 {
4350
+                let last = powerbankCheckpoints.last!
4351
+                if let earlier = powerbankCheckpoints.first(where: {
4352
+                    abs(last.batteryPercent - $0.batteryPercent) >= 30
4353
+                }) {
4354
+                    let lower = min(earlier.timestamp, last.timestamp)
4355
+                    let upper = max(earlier.timestamp, last.timestamp)
4356
+                    let energyDelivered = sessionsAsSource
4357
+                        .filter { $0.startedAt <= upper && ($0.endedAt ?? $0.lastObservedAt) >= lower }
4358
+                        .reduce(0.0) { $0 + $1.measuredEnergyWh }
4359
+                    if energyDelivered > 0.1 {
4360
+                        apparentCapacity = energyDelivered
4361
+                    }
4362
+                }
4363
+            }
4364
+        case .none:
4365
+            break
4366
+        }
4367
+
4368
+        return (voltageMaxCurrents, maxPower, efficiency, apparentCapacity)
4369
+    }
4370
+
3316 4371
     private func chargingTransportMode(for session: NSManagedObject) -> ChargingTransportMode {
3317 4372
         if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
3318 4373
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
+356 -0
USB Meter/Model/ConsumptionMonitorStore.swift
@@ -0,0 +1,356 @@
1
+//
2
+//  ConsumptionMonitorStore.swift
3
+//  USB Meter
4
+//
5
+
6
+import Foundation
7
+import Combine
8
+
9
+// MARK: - Store
10
+
11
+final class ConsumptionMonitorStore {
12
+    private struct Snapshot: Codable {
13
+        var sessions: [ConsumptionMonitorSessionSummary]
14
+    }
15
+
16
+    private enum Keys {
17
+        static let cloudSessions = "ConsumptionMonitorStore.sessions"
18
+    }
19
+
20
+    private let fileManager: FileManager
21
+    private let fileURL: URL
22
+    private let encoder: JSONEncoder
23
+    private let decoder: JSONDecoder
24
+    private let ubiquitousStore = NSUbiquitousKeyValueStore.default
25
+    private let workQueue = DispatchQueue(label: "ConsumptionMonitorStore.Queue")
26
+    private var ubiquitousObserver: NSObjectProtocol?
27
+    private var ubiquityIdentityObserver: NSObjectProtocol?
28
+
29
+    private var cachedSessions: [ConsumptionMonitorSessionSummary]?
30
+
31
+    init(fileManager: FileManager = .default) {
32
+        self.fileManager = fileManager
33
+
34
+        let applicationSupportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
35
+            ?? fileManager.urls(for: .documentDirectory, in: .userDomainMask).first
36
+            ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
37
+
38
+        let directoryURL = applicationSupportURL.appendingPathComponent("ChargeInsights", isDirectory: true)
39
+        fileURL = directoryURL.appendingPathComponent("consumption-monitor.json", isDirectory: false)
40
+
41
+        encoder = JSONEncoder()
42
+        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
43
+        encoder.dateEncodingStrategy = .iso8601
44
+
45
+        decoder = JSONDecoder()
46
+        decoder.dateDecodingStrategy = .iso8601
47
+
48
+        ubiquitousObserver = NotificationCenter.default.addObserver(
49
+            forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
50
+            object: ubiquitousStore,
51
+            queue: nil
52
+        ) { [weak self] notification in
53
+            self?.handleUbiquitousStoreChange(notification)
54
+        }
55
+
56
+        ubiquityIdentityObserver = NotificationCenter.default.addObserver(
57
+            forName: NSNotification.Name.NSUbiquityIdentityDidChange,
58
+            object: nil,
59
+            queue: nil
60
+        ) { [weak self] _ in
61
+            self?.syncLocalValuesToCloudIfPossible(reason: "identity-changed")
62
+        }
63
+
64
+        ubiquitousStore.synchronize()
65
+        syncLocalValuesToCloudIfPossible(reason: "startup")
66
+    }
67
+
68
+    func sessionsByDeviceID() -> [UUID: [ConsumptionMonitorSessionSummary]] {
69
+        Dictionary(grouping: loadSessions()) { $0.chargedDeviceID }
70
+            .mapValues { sessions in
71
+                sessions.sorted { lhs, rhs in
72
+                    (lhs.endedAt ?? .distantFuture) > (rhs.endedAt ?? .distantFuture)
73
+                }
74
+            }
75
+    }
76
+
77
+    @discardableResult
78
+    func save(_ session: ConsumptionMonitorSessionSummary) -> Bool {
79
+        var sessions = loadSessions()
80
+        if let index = sessions.firstIndex(where: { $0.id == session.id }) {
81
+            sessions[index] = session
82
+        } else {
83
+            sessions.append(session)
84
+        }
85
+        sessions.sort { lhs, rhs in
86
+            (lhs.endedAt ?? .distantFuture) > (rhs.endedAt ?? .distantFuture)
87
+        }
88
+        return persist(sessions)
89
+    }
90
+
91
+    @discardableResult
92
+    func appendSample(_ sample: ConsumptionMonitorSample, to sessionID: UUID) -> Bool {
93
+        var sessions = loadSessions()
94
+        guard let index = sessions.firstIndex(where: { $0.id == sessionID }) else {
95
+            return false
96
+        }
97
+        sessions[index].samples.append(sample)
98
+        return persist(sessions)
99
+    }
100
+
101
+    @discardableResult
102
+    func completeSession(id sessionID: UUID, endedAt: Date) -> Bool {
103
+        var sessions = loadSessions()
104
+        guard let index = sessions.firstIndex(where: { $0.id == sessionID }) else {
105
+            return false
106
+        }
107
+        sessions[index].endedAt = endedAt
108
+        sessions.sort { lhs, rhs in
109
+            (lhs.endedAt ?? .distantFuture) > (rhs.endedAt ?? .distantFuture)
110
+        }
111
+        return persist(sessions)
112
+    }
113
+
114
+    @discardableResult
115
+    func removeSession(id: UUID, deviceID: UUID) -> Bool {
116
+        let previous = loadSessions()
117
+        let filtered = previous.filter { !($0.id == id && $0.chargedDeviceID == deviceID) }
118
+        guard filtered.count != previous.count else { return true }
119
+        return persist(filtered)
120
+    }
121
+
122
+    @discardableResult
123
+    func removeSessions(for deviceID: UUID) -> Bool {
124
+        let previous = loadSessions()
125
+        let filtered = previous.filter { $0.chargedDeviceID != deviceID }
126
+        guard filtered.count != previous.count else { return true }
127
+        return persist(filtered)
128
+    }
129
+
130
+    func openSession(for meterMACAddress: String) -> ConsumptionMonitorSessionSummary? {
131
+        loadSessions().first { $0.isOpen && $0.meterMACAddress == meterMACAddress }
132
+    }
133
+
134
+    // MARK: - Private
135
+
136
+    private func loadSessions() -> [ConsumptionMonitorSessionSummary] {
137
+        if let cachedSessions { return cachedSessions }
138
+        let local = loadLocalSessions()
139
+        let cloud = loadCloudSessions()
140
+        let merged = merge(localSessions: local, cloudSessions: cloud)
141
+        cachedSessions = merged
142
+        return merged
143
+    }
144
+
145
+    private func loadLocalSessions() -> [ConsumptionMonitorSessionSummary] {
146
+        guard fileManager.fileExists(atPath: fileURL.path) else { return [] }
147
+        do {
148
+            let data = try Data(contentsOf: fileURL)
149
+            return try decoder.decode(Snapshot.self, from: data).sessions
150
+        } catch {
151
+            track("ConsumptionMonitorStore: failed to load local sessions: \(error.localizedDescription)")
152
+            return []
153
+        }
154
+    }
155
+
156
+    private func loadCloudSessions() -> [ConsumptionMonitorSessionSummary] {
157
+        guard isICloudDriveAvailable,
158
+              let data = ubiquitousStore.data(forKey: Keys.cloudSessions) else { return [] }
159
+        do {
160
+            return try decoder.decode(Snapshot.self, from: data).sessions
161
+        } catch {
162
+            track("ConsumptionMonitorStore: failed to decode cloud sessions: \(error.localizedDescription)")
163
+            return []
164
+        }
165
+    }
166
+
167
+    @discardableResult
168
+    private func persist(_ sessions: [ConsumptionMonitorSessionSummary]) -> Bool {
169
+        let didLocal = persistLocally(sessions)
170
+        let didCloud = persistToCloudIfPossible(sessions)
171
+        if didLocal || didCloud {
172
+            cachedSessions = sessions
173
+        }
174
+        return didLocal || didCloud
175
+    }
176
+
177
+    @discardableResult
178
+    private func persistLocally(_ sessions: [ConsumptionMonitorSessionSummary]) -> Bool {
179
+        do {
180
+            try fileManager.createDirectory(
181
+                at: fileURL.deletingLastPathComponent(),
182
+                withIntermediateDirectories: true,
183
+                attributes: nil
184
+            )
185
+            let data = try encoder.encode(Snapshot(sessions: sessions))
186
+            try data.write(to: fileURL, options: .atomic)
187
+            return true
188
+        } catch {
189
+            track("ConsumptionMonitorStore: failed to save locally: \(error.localizedDescription)")
190
+            return false
191
+        }
192
+    }
193
+
194
+    @discardableResult
195
+    private func persistToCloudIfPossible(_ sessions: [ConsumptionMonitorSessionSummary]) -> Bool {
196
+        guard isICloudDriveAvailable else { return false }
197
+        do {
198
+            let data = try encoder.encode(Snapshot(sessions: sessions))
199
+            ubiquitousStore.set(data, forKey: Keys.cloudSessions)
200
+            return ubiquitousStore.synchronize()
201
+        } catch {
202
+            track("ConsumptionMonitorStore: failed to sync to cloud: \(error.localizedDescription)")
203
+            return false
204
+        }
205
+    }
206
+
207
+    private func merge(
208
+        localSessions: [ConsumptionMonitorSessionSummary],
209
+        cloudSessions: [ConsumptionMonitorSessionSummary]
210
+    ) -> [ConsumptionMonitorSessionSummary] {
211
+        var byID: [UUID: ConsumptionMonitorSessionSummary] = [:]
212
+        for session in localSessions { byID[session.id] = session }
213
+        for session in cloudSessions {
214
+            if let existing = byID[session.id] {
215
+                // Keep the one with more samples or a definitive end time
216
+                if session.samples.count > existing.samples.count || (session.endedAt != nil && existing.endedAt == nil) {
217
+                    byID[session.id] = session
218
+                }
219
+            } else {
220
+                byID[session.id] = session
221
+            }
222
+        }
223
+        return byID.values.sorted { lhs, rhs in
224
+            (lhs.endedAt ?? .distantFuture) > (rhs.endedAt ?? .distantFuture)
225
+        }
226
+    }
227
+
228
+    private func syncLocalValuesToCloudIfPossible(reason: String) {
229
+        let sessions = loadLocalSessions()
230
+        guard !sessions.isEmpty else { return }
231
+        persistToCloudIfPossible(sessions)
232
+    }
233
+
234
+    private func handleUbiquitousStoreChange(_ notification: Notification) {
235
+        cachedSessions = nil
236
+        NotificationCenter.default.post(name: .consumptionMonitorStoreDidChange, object: nil)
237
+    }
238
+
239
+    private var isICloudDriveAvailable: Bool {
240
+        FileManager.default.ubiquityIdentityToken != nil
241
+    }
242
+}
243
+
244
+extension Notification.Name {
245
+    static let consumptionMonitorStoreDidChange = Notification.Name("ConsumptionMonitorStore.DidChange")
246
+}
247
+
248
+// MARK: - Live Session
249
+
250
+final class ConsumptionMonitorLiveSession: ObservableObject {
251
+    static let bucketDurationSeconds: TimeInterval = 60
252
+
253
+    let sessionID: UUID
254
+    let chargedDeviceID: UUID
255
+    let meterMACAddress: String
256
+    let startedAt: Date
257
+
258
+    @Published private(set) var currentPowerWatts: Double = 0
259
+    @Published private(set) var currentCurrentAmps: Double = 0
260
+    @Published private(set) var currentVoltageVolts: Double = 0
261
+    @Published private(set) var committedSampleCount: Int = 0
262
+    @Published private(set) var committedSamples: [ConsumptionMonitorSample] = []
263
+    @Published private(set) var cumulativeEnergyWh: Double = 0
264
+    @Published private(set) var isRunning: Bool = false
265
+
266
+    var meterName: String?
267
+    var meterModel: String?
268
+    var onSample: ((ConsumptionMonitorSample) -> Void)?
269
+    var onChange: (() -> Void)?
270
+
271
+    private var flushTimer: Timer?
272
+    private var powerReadings: [(power: Double, current: Double, voltage: Double)] = []
273
+    private var lastObservationTime: Date?
274
+    private var nextBucketIndex: Int = 0
275
+
276
+    init(sessionID: UUID, chargedDeviceID: UUID, meterMACAddress: String, startedAt: Date) {
277
+        self.sessionID = sessionID
278
+        self.chargedDeviceID = chargedDeviceID
279
+        self.meterMACAddress = meterMACAddress
280
+        self.startedAt = startedAt
281
+    }
282
+
283
+    func start() {
284
+        guard !isRunning else { return }
285
+        isRunning = true
286
+        scheduleNextFlush()
287
+    }
288
+
289
+    func stop() {
290
+        isRunning = false
291
+        flushTimer?.invalidate()
292
+        flushTimer = nil
293
+        flushBucket()
294
+    }
295
+
296
+    func observe(powerWatts: Double, currentAmps: Double, voltageVolts: Double, observedAt: Date) {
297
+        currentPowerWatts = powerWatts
298
+        currentCurrentAmps = currentAmps
299
+        currentVoltageVolts = voltageVolts
300
+
301
+        if let last = lastObservationTime {
302
+            let dtHours = observedAt.timeIntervalSince(last) / 3600
303
+            cumulativeEnergyWh += powerWatts * dtHours
304
+        }
305
+        lastObservationTime = observedAt
306
+
307
+        powerReadings.append((powerWatts, currentAmps, voltageVolts))
308
+        onChange?()
309
+    }
310
+
311
+    var elapsedDuration: TimeInterval {
312
+        Date().timeIntervalSince(startedAt)
313
+    }
314
+
315
+    // MARK: - Private
316
+
317
+    private func scheduleNextFlush() {
318
+        flushTimer?.invalidate()
319
+        flushTimer = Timer.scheduledTimer(
320
+            withTimeInterval: Self.bucketDurationSeconds,
321
+            repeats: false
322
+        ) { [weak self] _ in
323
+            self?.flushBucket()
324
+            if self?.isRunning == true {
325
+                self?.scheduleNextFlush()
326
+            }
327
+        }
328
+    }
329
+
330
+    private func flushBucket() {
331
+        guard !powerReadings.isEmpty else { return }
332
+
333
+        let n = Double(powerReadings.count)
334
+        let avgPower = powerReadings.map(\.power).reduce(0, +) / n
335
+        let avgCurrent = powerReadings.map(\.current).reduce(0, +) / n
336
+        let avgVoltage = powerReadings.map(\.voltage).reduce(0, +) / n
337
+        let bucketIndex = nextBucketIndex
338
+        nextBucketIndex += 1
339
+
340
+        let sample = ConsumptionMonitorSample(
341
+            bucketIndex: bucketIndex,
342
+            timestamp: Date(),
343
+            averagePowerWatts: avgPower,
344
+            averageCurrentAmps: avgCurrent,
345
+            averageVoltageVolts: avgVoltage,
346
+            sampleCount: powerReadings.count,
347
+            cumulativeEnergyWh: cumulativeEnergyWh
348
+        )
349
+
350
+        powerReadings = []
351
+        committedSamples.append(sample)
352
+        committedSampleCount = nextBucketIndex
353
+        onSample?(sample)
354
+        onChange?()
355
+    }
356
+}
+187 -1
USB Meter/Model/Measurements.swift
@@ -267,6 +267,7 @@ class Measurements : ObservableObject {
267 267
     @Published var temperature = Measurement()
268 268
     @Published var energy = Measurement()
269 269
     @Published var rssi = Measurement()
270
+    @Published var batteryPercent = Measurement()
270 271
 
271 272
     let averagePowerSampleOptions: [Int] = [5, 10, 20, 50, 100, 250]
272 273
 
@@ -328,7 +329,8 @@ class Measurements : ObservableObject {
328 329
             current.points.isEmpty == false ||
329 330
             temperature.points.isEmpty == false ||
330 331
             energy.points.isEmpty == false ||
331
-            rssi.points.isEmpty == false
332
+            rssi.points.isEmpty == false ||
333
+            batteryPercent.points.isEmpty == false
332 334
 
333 335
         restoreTrace(
334 336
             "measurements-restore-start session=\(session.id.uuidString) status=\(session.status.rawValue) persistedSamples=\(session.aggregatedSamples.count) replaceLive=\(replacingLiveBufferIfNeeded) existingBuffer=\(hasExistingBuffer) existingCounts=p:\(power.points.count),v:\(voltage.points.count),c:\(current.points.count),t:\(temperature.points.count),e:\(energy.points.count),r:\(rssi.points.count)"
@@ -372,6 +374,9 @@ class Measurements : ObservableObject {
372 374
         let restoredEnergyPoints = restoredPoints(from: sortedSamples) { sample in
373 375
             sample.measuredEnergyWh
374 376
         }
377
+        let restoredBatteryPercentPoints = restoredPoints(from: sortedSamples) { sample in
378
+            sample.estimatedBatteryPercent ?? estimatedBatteryPercent(for: sample, in: session)
379
+        }
375 380
 
376 381
         let mergedPowerPoints = mergedRestoredPoints(
377 382
             restored: restoredPowerPoints,
@@ -393,6 +398,11 @@ class Measurements : ObservableObject {
393 398
             existing: energy.points,
394 399
             persistedRangeUpperBound: persistedRangeUpperBound
395 400
         )
401
+        let mergedBatteryPercentPoints = mergedRestoredPoints(
402
+            restored: restoredBatteryPercentPoints,
403
+            existing: batteryPercent.points,
404
+            persistedRangeUpperBound: persistedRangeUpperBound
405
+        )
396 406
         let preservedRssiTail = preservedTailPoints(
397 407
             from: rssi.points,
398 408
             after: persistedRangeUpperBound
@@ -406,6 +416,7 @@ class Measurements : ObservableObject {
406 416
         current.replacePoints(mergedCurrentPoints)
407 417
         voltage.replacePoints(mergedVoltagePoints)
408 418
         energy.replacePoints(mergedEnergyPoints)
419
+        batteryPercent.replacePoints(mergedBatteryPercentPoints)
409 420
         temperature.resetSeries()
410 421
         rssi.replacePoints(preservedRssiTail)
411 422
 
@@ -455,6 +466,175 @@ class Measurements : ObservableObject {
455 466
         return restored
456 467
     }
457 468
 
469
+    private func estimatedBatteryPercent(
470
+        for sample: ChargeSessionSampleSummary,
471
+        in session: ChargeSessionSummary
472
+    ) -> Double? {
473
+        let estimatedCapacityWh = session.capacityEstimateWh
474
+
475
+        func anchorCapacityCandidates(from anchors: [BatteryLevelPredictionAnchor]) -> [Double] {
476
+            var candidates: [Double] = []
477
+
478
+            for lowerIndex in anchors.indices {
479
+                for upperIndex in anchors.indices where upperIndex > lowerIndex {
480
+                    let lower = anchors[lowerIndex]
481
+                    let upper = anchors[upperIndex]
482
+                    let percentDelta = upper.percent - lower.percent
483
+                    let energyDelta = upper.energyWh - lower.energyWh
484
+
485
+                    guard percentDelta >= 3, energyDelta > 0.01 else {
486
+                        continue
487
+                    }
488
+
489
+                    let capacityWh = energyDelta / (percentDelta / 100)
490
+                    guard capacityWh.isFinite, capacityWh > 0, capacityWh < 1_000 else {
491
+                        continue
492
+                    }
493
+
494
+                    candidates.append(capacityWh)
495
+                }
496
+            }
497
+
498
+            return candidates
499
+        }
500
+
501
+        func inferredCheckpointEnergyMapCapacityWh(from anchors: [BatteryLevelPredictionAnchor]) -> Double? {
502
+            let candidates = anchorCapacityCandidates(from: anchors)
503
+            guard !candidates.isEmpty else {
504
+                return nil
505
+            }
506
+
507
+            let sortedCandidates = candidates.sorted()
508
+            return sortedCandidates[sortedCandidates.count / 2]
509
+        }
510
+
511
+        var anchors: [BatteryLevelPredictionAnchor] = []
512
+        if let startBatteryPercent = session.startBatteryPercent,
513
+           startBatteryPercent >= 0 {
514
+            anchors.append(
515
+                BatteryLevelPredictionAnchor(
516
+                    percent: startBatteryPercent,
517
+                    energyWh: 0,
518
+                    timestamp: session.effectiveTrimStart,
519
+                    description: "session start",
520
+                    isCheckpoint: false
521
+                )
522
+            )
523
+        }
524
+
525
+        anchors.append(
526
+            contentsOf: session.checkpoints
527
+                .filter { $0.batteryPercent >= 0 }
528
+                .sorted { lhs, rhs in
529
+                    if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
530
+                        return lhs.measuredEnergyWh < rhs.measuredEnergyWh
531
+                    }
532
+                    return lhs.timestamp < rhs.timestamp
533
+                }
534
+                .map {
535
+                    BatteryLevelPredictionAnchor(
536
+                        percent: $0.batteryPercent,
537
+                        energyWh: $0.measuredEnergyWh,
538
+                        timestamp: $0.timestamp,
539
+                        description: $0.flag.anchorDescription,
540
+                        isCheckpoint: true
541
+                    )
542
+                }
543
+        )
544
+
545
+        let effectiveEnergyWh = effectiveBatteryEnergyWh(for: sample, in: session)
546
+
547
+        if session.startsFromFlatBattery {
548
+            if let virtualZeroEnergyWh = BatteryLevelPredictionTuning.inferredVirtualZeroEnergyWh(
549
+                from: anchors,
550
+                estimatedCapacityWh: estimatedCapacityWh
551
+            ) {
552
+                anchors.append(
553
+                    BatteryLevelPredictionAnchor(
554
+                        percent: 0,
555
+                        energyWh: virtualZeroEnergyWh,
556
+                        timestamp: session.effectiveTrimStart,
557
+                        description: "estimated flat reserve",
558
+                        isCheckpoint: false
559
+                    )
560
+                )
561
+            } else if let firstCheckpoint = anchors.min(by: { $0.energyWh < $1.energyWh }),
562
+                      effectiveEnergyWh < firstCheckpoint.energyWh - 0.05 {
563
+                return nil
564
+            }
565
+        }
566
+
567
+        let sortedAnchors = anchors.sorted { lhs, rhs in
568
+            if lhs.energyWh != rhs.energyWh {
569
+                return lhs.energyWh < rhs.energyWh
570
+            }
571
+            return lhs.timestamp < rhs.timestamp
572
+        }
573
+
574
+        guard !sortedAnchors.isEmpty else { return nil }
575
+
576
+        let lowerAnchor = sortedAnchors.last { $0.energyWh <= effectiveEnergyWh + 0.05 }
577
+        let upperAnchor = sortedAnchors.first { $0.energyWh > effectiveEnergyWh + 0.05 }
578
+        let anchor = lowerAnchor ?? upperAnchor ?? sortedAnchors.first!
579
+
580
+        if let lowerAnchor,
581
+           let upperAnchor,
582
+           upperAnchor.energyWh > lowerAnchor.energyWh + 0.05 {
583
+            let interpolationProgress = min(
584
+                max(
585
+                    (effectiveEnergyWh - lowerAnchor.energyWh) /
586
+                    (upperAnchor.energyWh - lowerAnchor.energyWh),
587
+                    0
588
+                ),
589
+                1
590
+            )
591
+            return min(
592
+                max(
593
+                    lowerAnchor.percent +
594
+                    (upperAnchor.percent - lowerAnchor.percent) * interpolationProgress,
595
+                    0
596
+                ),
597
+                100
598
+            )
599
+        }
600
+
601
+        let inferredCapacityWh = estimatedCapacityWh
602
+            ?? inferredCheckpointEnergyMapCapacityWh(from: sortedAnchors)
603
+
604
+        guard let inferredCapacityWh, inferredCapacityWh > 0 else {
605
+            return nil
606
+        }
607
+
608
+        return BatteryLevelPredictionTuning.predictedPercent(
609
+            anchorPercent: anchor.percent,
610
+            anchorEnergyWh: anchor.energyWh,
611
+            anchorTimestamp: anchor.timestamp,
612
+            anchorIsCheckpoint: anchor.isCheckpoint,
613
+            effectiveEnergyWh: effectiveEnergyWh,
614
+            referenceTimestamp: sample.timestamp,
615
+            estimatedCapacityWh: inferredCapacityWh
616
+        )
617
+    }
618
+
619
+    private func effectiveBatteryEnergyWh(
620
+        for sample: ChargeSessionSampleSummary,
621
+        in session: ChargeSessionSummary
622
+    ) -> Double {
623
+        switch session.chargingTransportMode {
624
+        case .wired:
625
+            return sample.measuredEnergyWh
626
+        case .wireless:
627
+            if let factor = session.wirelessEfficiencyFactor, factor > 0 {
628
+                return sample.measuredEnergyWh * factor
629
+            }
630
+            if let sessionEffectiveEnergyWh = session.effectiveBatteryEnergyWh,
631
+               session.measuredEnergyWh > 0 {
632
+                return sample.measuredEnergyWh * (sessionEffectiveEnergyWh / session.measuredEnergyWh)
633
+            }
634
+            return sample.measuredEnergyWh
635
+        }
636
+    }
637
+
458 638
     private func mergedRestoredPoints(
459 639
         restored: [Measurement.Point],
460 640
         existing: [Measurement.Point],
@@ -519,6 +699,7 @@ class Measurements : ObservableObject {
519 699
         temperature.resetSeries()
520 700
         energy.resetSeries()
521 701
         rssi.resetSeries()
702
+        batteryPercent.resetSeries()
522 703
         resetPendingAggregation()
523 704
         lastEnergyCounterValue = nil
524 705
         lastEnergyGroupID = nil
@@ -537,6 +718,7 @@ class Measurements : ObservableObject {
537 718
         temperature.removeValue(index: idx)
538 719
         energy.removeValue(index: idx)
539 720
         rssi.removeValue(index: idx)
721
+        batteryPercent.removeValue(index: idx)
540 722
         realignEnergyBufferStart()
541 723
         self.objectWillChange.send()
542 724
     }
@@ -549,6 +731,7 @@ class Measurements : ObservableObject {
549 731
         temperature.trim(before: cutoff)
550 732
         energy.trim(before: cutoff)
551 733
         rssi.trim(before: cutoff)
734
+        batteryPercent.trim(before: cutoff)
552 735
         realignEnergyBufferStart()
553 736
         self.objectWillChange.send()
554 737
     }
@@ -561,6 +744,7 @@ class Measurements : ObservableObject {
561 744
         temperature.filterSamples { range.contains($0) }
562 745
         energy.filterSamples { range.contains($0) }
563 746
         rssi.filterSamples { range.contains($0) }
747
+        batteryPercent.filterSamples { range.contains($0) }
564 748
         realignEnergyBufferStart()
565 749
         self.objectWillChange.send()
566 750
     }
@@ -573,6 +757,7 @@ class Measurements : ObservableObject {
573 757
         temperature.filterSamples { !range.contains($0) }
574 758
         energy.filterSamples { !range.contains($0) }
575 759
         rssi.filterSamples { !range.contains($0) }
760
+        batteryPercent.filterSamples { !range.contains($0) }
576 761
         realignEnergyBufferStart()
577 762
         self.objectWillChange.send()
578 763
     }
@@ -620,6 +805,7 @@ class Measurements : ObservableObject {
620 805
         temperature.addDiscontinuity(timestamp: timestamp)
621 806
         energy.addDiscontinuity(timestamp: timestamp)
622 807
         rssi.addDiscontinuity(timestamp: timestamp)
808
+        batteryPercent.addDiscontinuity(timestamp: timestamp)
623 809
         self.objectWillChange.send()
624 810
     }
625 811
 
+3 -16
USB Meter/Model/Meter.swift
@@ -778,7 +778,6 @@ class Meter : NSObject, ObservableObject, Identifiable {
778 778
                 btSerial.write(TC66Protocol.snapshotRequest, expectedResponseLength: 192)
779 779
             }
780 780
             dataDumpRequestTimestamp = Date()
781
-            // track("\(name) - Request sent!")
782 781
         } else {
783 782
             track("Request delayed for: \(commandQueue.first!.hexEncodedStringValue)")
784 783
             btSerial.write( commandQueue.first! )
@@ -793,7 +792,6 @@ class Meter : NSObject, ObservableObject, Identifiable {
793 792
      - Decription metod for TC66C AES ECB response  found [here](https:github.com/krzyzanowskim/CryptoSwift/issues/693)
794 793
      */
795 794
     func parseData ( from buffer: Data) {
796
-        //track("\(name)")
797 795
         liveDataChanged = false
798 796
         switch model {
799 797
         case .UM25C:
@@ -825,9 +823,6 @@ class Meter : NSObject, ObservableObject, Identifiable {
825 823
             )
826 824
         }
827 825
         appData.observeChargeSnapshot(from: self, observedAt: dataDumpRequestTimestamp)
828
-//        DispatchQueue.global(qos: .userInitiated).asyncAfter( deadline: .now() + 0.33 ) {
829
-//            //track("\(name) - Scheduled new request.")
830
-//        }
831 826
         if operationalState != .dataIsAvailable {
832 827
             operationalState = .dataIsAvailable
833 828
         } else if liveDataChanged {
@@ -992,10 +987,11 @@ class Meter : NSObject, ObservableObject, Identifiable {
992 987
         )
993 988
 
994 989
         if restoreSignature != restoredChargeRecordSignature {
995
-            restoreTrace("meter=\(name) charge-record-restore-start session=\(activeSession.id.uuidString) status=\(activeSession.status.rawValue) samples=\(restoreSignature.sampleCount) lastTs=\(restoreSignature.lastSampleTimestamp?.description ?? "nil") replaceLive=\(replacingLiveBufferIfNeeded) state=\(chargeRecordState) existingPower=\(chargeRecordMeasurements.power.samplePoints.count)")
990
+            let shouldRefreshPersistedBuffer = replacingLiveBufferIfNeeded
991
+            restoreTrace("meter=\(name) charge-record-restore-start session=\(activeSession.id.uuidString) status=\(activeSession.status.rawValue) samples=\(restoreSignature.sampleCount) lastTs=\(restoreSignature.lastSampleTimestamp?.description ?? "nil") replaceLive=\(shouldRefreshPersistedBuffer) state=\(chargeRecordState) existingPower=\(chargeRecordMeasurements.power.samplePoints.count)")
996 992
             let didRestorePersistedSamples = chargeRecordMeasurements.restorePersistedChargeSessionSamplesIfNeeded(
997 993
                 from: activeSession,
998
-                replacingLiveBufferIfNeeded: replacingLiveBufferIfNeeded
994
+                replacingLiveBufferIfNeeded: shouldRefreshPersistedBuffer
999 995
             )
1000 996
             restoreTrace("meter=\(name) charge-record-restore-result session=\(activeSession.id.uuidString) didRestore=\(didRestorePersistedSamples) priorSignatureSamples=\(restoredChargeRecordSignature?.sampleCount.description ?? "nil")")
1001 997
             if didRestorePersistedSamples || activeSession.aggregatedSamples.isEmpty == false {
@@ -1011,12 +1007,6 @@ class Meter : NSObject, ObservableObject, Identifiable {
1011 1007
             didChange = true
1012 1008
         }
1013 1009
 
1014
-        let resolvedChargeAH = max(chargeRecordAH, activeSession.measuredChargeAh)
1015
-        if resolvedChargeAH != chargeRecordAH {
1016
-            chargeRecordAH = resolvedChargeAH
1017
-            didChange = true
1018
-        }
1019
-
1020 1010
         let resolvedChargeWH = max(chargeRecordWH, activeSession.measuredEnergyWh)
1021 1011
         if resolvedChargeWH != chargeRecordWH {
1022 1012
             chargeRecordWH = resolvedChargeWH
@@ -1192,19 +1182,16 @@ class Meter : NSObject, ObservableObject, Identifiable {
1192 1182
     
1193 1183
     func selectDataGroup ( id: UInt8) {
1194 1184
         guard supportsDataGroupCommands else { return }
1195
-        track("\(name) - \(id)")
1196 1185
         selectedDataGroup = id
1197 1186
         objectWillChange.send()
1198 1187
         commandQueue.append(UMProtocol.selectDataGroup(selectedDataGroup))
1199 1188
     }
1200 1189
     
1201 1190
     private func setSceeenBrightness ( to value: UInt8) {
1202
-        track("\(name) - \(value)")
1203 1191
         guard supportsUMSettings else { return }
1204 1192
         commandQueue.append(UMProtocol.setScreenBrightness(value))
1205 1193
     }
1206 1194
     private func setScreenSaverTimeout ( to value: UInt8) {
1207
-        track("\(name) - \(value)")
1208 1195
         guard supportsUMSettings else { return }
1209 1196
         commandQueue.append(UMProtocol.setScreenSaverTimeout(value))
1210 1197
     }
+9 -1
USB Meter/Model/MeterCapabilities.swift
@@ -110,6 +110,14 @@ extension Model {
110 110
         }
111 111
     )
112 112
 
113
+    static var knownPeripheralNames: [String] {
114
+        allCases.flatMap(\.peripheralNames).sorted()
115
+    }
116
+
117
+    static func model(forPeripheralName peripheralName: String) -> Model? {
118
+        byPeripheralName[peripheralName] ?? byPeripheralName[peripheralName.uppercased()]
119
+    }
120
+
113 121
     var radio: BluetoothRadio {
114 122
         switch self {
115 123
         case .UM25C, .UM34C:
@@ -126,7 +134,7 @@ extension Model {
126 134
         case .UM34C:
127 135
             return ["UM34C"]
128 136
         case .TC66C:
129
-            return ["TC66C", "PW0316"]
137
+            return ["TC66C", "BT24-M"]
130 138
         }
131 139
     }
132 140
 
+256 -0
USB Meter/Templates/DeviceProfilesCatalog.json
@@ -0,0 +1,256 @@
1
+{
2
+  "profiles": [
3
+    {
4
+      "id": "apple-iphone",
5
+      "name": "iPhone",
6
+      "group": "Apple",
7
+      "category": "phone",
8
+      "icon": { "type": "systemSymbol", "name": "iphone", "fallbackSystemName": "smartphone" },
9
+      "sortOrder": 10,
10
+      "capWiredCharging": true,
11
+      "capWirelessCharging": true,
12
+      "capWirelessProfiles": ["magsafe", "genericQi"],
13
+      "capChargingStateAvailability": "onOrOff",
14
+      "capHasInternalSubject": false,
15
+      "defaultWirelessChargingProfile": "magsafe"
16
+    },
17
+    {
18
+      "id": "apple-ipad",
19
+      "name": "iPad",
20
+      "group": "Apple",
21
+      "category": "tablet",
22
+      "icon": { "type": "systemSymbol", "name": "ipad", "fallbackSystemName": "rectangle" },
23
+      "sortOrder": 20,
24
+      "capWiredCharging": true,
25
+      "capWirelessCharging": false,
26
+      "capWirelessProfiles": [],
27
+      "capChargingStateAvailability": "onOrOff",
28
+      "capHasInternalSubject": false,
29
+      "defaultWirelessChargingProfile": null
30
+    },
31
+    {
32
+      "id": "apple-watch",
33
+      "name": "Apple Watch",
34
+      "group": "Apple",
35
+      "category": "watch",
36
+      "icon": { "type": "systemSymbol", "name": "applewatch", "fallbackSystemName": "watch.analog" },
37
+      "sortOrder": 30,
38
+      "capWiredCharging": false,
39
+      "capWirelessCharging": true,
40
+      "capWirelessProfiles": ["genericQi"],
41
+      "capChargingStateAvailability": "onOnly",
42
+      "capHasInternalSubject": false,
43
+      "defaultWirelessChargingProfile": "genericQi"
44
+    },
45
+    {
46
+      "id": "apple-airpods",
47
+      "name": "AirPods",
48
+      "group": "Apple",
49
+      "category": "audioAccessory",
50
+      "icon": { "type": "systemSymbol", "name": "airpods", "fallbackSystemName": "earbuds.case" },
51
+      "sortOrder": 40,
52
+      "capWiredCharging": true,
53
+      "capWirelessCharging": true,
54
+      "capWirelessProfiles": ["genericQi"],
55
+      "capChargingStateAvailability": "onOnly",
56
+      "capHasInternalSubject": false,
57
+      "defaultWirelessChargingProfile": "genericQi"
58
+    },
59
+    {
60
+      "id": "apple-airpods-case",
61
+      "name": "AirPods Case",
62
+      "group": "Apple",
63
+      "category": "accessoryCase",
64
+      "icon": { "type": "systemSymbol", "name": "airpods.case.fill", "fallbackSystemName": "earbuds.case" },
65
+      "sortOrder": 50,
66
+      "capWiredCharging": true,
67
+      "capWirelessCharging": true,
68
+      "capWirelessProfiles": ["magsafe", "genericQi"],
69
+      "capChargingStateAvailability": "onOnly",
70
+      "capHasInternalSubject": true,
71
+      "defaultWirelessChargingProfile": "genericQi"
72
+    },
73
+    {
74
+      "id": "apple-pencil",
75
+      "name": "Apple Pencil",
76
+      "group": "Apple",
77
+      "category": "accessoryCase",
78
+      "icon": { "type": "systemSymbol", "name": "applepencil", "fallbackSystemName": "pencil" },
79
+      "sortOrder": 60,
80
+      "capWiredCharging": true,
81
+      "capWirelessCharging": true,
82
+      "capWirelessProfiles": ["genericQi"],
83
+      "capChargingStateAvailability": "onOnly",
84
+      "capHasInternalSubject": false,
85
+      "defaultWirelessChargingProfile": "genericQi"
86
+    },
87
+    {
88
+      "id": "generic-phone",
89
+      "name": "Phone",
90
+      "group": "Generic",
91
+      "category": "phone",
92
+      "icon": { "type": "systemSymbol", "name": "smartphone", "fallbackSystemName": "rectangle.portrait" },
93
+      "sortOrder": 110,
94
+      "capWiredCharging": true,
95
+      "capWirelessCharging": true,
96
+      "capWirelessProfiles": ["genericQi"],
97
+      "capChargingStateAvailability": "onOrOff",
98
+      "capHasInternalSubject": false,
99
+      "defaultWirelessChargingProfile": "genericQi"
100
+    },
101
+    {
102
+      "id": "generic-tablet",
103
+      "name": "Tablet",
104
+      "group": "Generic",
105
+      "category": "tablet",
106
+      "icon": { "type": "systemSymbol", "name": "rectangle", "fallbackSystemName": "rectangle" },
107
+      "sortOrder": 120,
108
+      "capWiredCharging": true,
109
+      "capWirelessCharging": false,
110
+      "capWirelessProfiles": [],
111
+      "capChargingStateAvailability": "onOrOff",
112
+      "capHasInternalSubject": false,
113
+      "defaultWirelessChargingProfile": null
114
+    },
115
+    {
116
+      "id": "generic-watch",
117
+      "name": "Watch",
118
+      "group": "Generic",
119
+      "category": "watch",
120
+      "icon": { "type": "systemSymbol", "name": "watch.analog", "fallbackSystemName": "clock" },
121
+      "sortOrder": 130,
122
+      "capWiredCharging": false,
123
+      "capWirelessCharging": true,
124
+      "capWirelessProfiles": ["genericQi"],
125
+      "capChargingStateAvailability": "onOnly",
126
+      "capHasInternalSubject": false,
127
+      "defaultWirelessChargingProfile": "genericQi"
128
+    },
129
+    {
130
+      "id": "generic-laptop",
131
+      "name": "Laptop",
132
+      "group": "Generic",
133
+      "category": "laptop",
134
+      "icon": { "type": "systemSymbol", "name": "laptopcomputer", "fallbackSystemName": "display" },
135
+      "sortOrder": 140,
136
+      "capWiredCharging": true,
137
+      "capWirelessCharging": false,
138
+      "capWirelessProfiles": [],
139
+      "capChargingStateAvailability": "onOrOff",
140
+      "capHasInternalSubject": false,
141
+      "defaultWirelessChargingProfile": null
142
+    },
143
+    {
144
+      "id": "generic-audio-accessory",
145
+      "name": "Audio Accessory",
146
+      "group": "Generic",
147
+      "category": "audioAccessory",
148
+      "icon": { "type": "systemSymbol", "name": "earbuds.case", "fallbackSystemName": "headphones" },
149
+      "sortOrder": 160,
150
+      "capWiredCharging": true,
151
+      "capWirelessCharging": true,
152
+      "capWirelessProfiles": ["genericQi"],
153
+      "capChargingStateAvailability": "onOnly",
154
+      "capHasInternalSubject": false,
155
+      "defaultWirelessChargingProfile": "genericQi"
156
+    },
157
+    {
158
+      "id": "generic-charging-case",
159
+      "name": "Charging Case",
160
+      "group": "Generic",
161
+      "category": "accessoryCase",
162
+      "icon": { "type": "systemSymbol", "name": "earbuds.case", "fallbackSystemName": "earbuds.case" },
163
+      "sortOrder": 165,
164
+      "capWiredCharging": true,
165
+      "capWirelessCharging": true,
166
+      "capWirelessProfiles": ["genericQi", "magsafe"],
167
+      "capChargingStateAvailability": "onOnly",
168
+      "capHasInternalSubject": true,
169
+      "defaultWirelessChargingProfile": "genericQi"
170
+    },
171
+    {
172
+      "id": "generic-device",
173
+      "name": "Other Device",
174
+      "group": "Generic",
175
+      "category": "other",
176
+      "icon": { "type": "systemSymbol", "name": "shippingbox", "fallbackSystemName": "shippingbox" },
177
+      "sortOrder": 170,
178
+      "capWiredCharging": true,
179
+      "capWirelessCharging": false,
180
+      "capWirelessProfiles": [],
181
+      "capChargingStateAvailability": "onOnly",
182
+      "capHasInternalSubject": false,
183
+      "defaultWirelessChargingProfile": null
184
+    },
185
+    {
186
+      "id": "apple-magsafe-charger",
187
+      "name": "Apple MagSafe Charger",
188
+      "group": "Apple",
189
+      "category": "charger",
190
+      "icon": { "type": "systemSymbol", "name": "magsafe.batterypack", "fallbackSystemName": "bolt.circle" },
191
+      "sortOrder": 210,
192
+      "capWiredCharging": false,
193
+      "capWirelessCharging": true,
194
+      "capWirelessProfiles": ["magsafe"],
195
+      "capChargingStateAvailability": "onOnly",
196
+      "capHasInternalSubject": false,
197
+      "defaultWirelessChargingProfile": "magsafe"
198
+    },
199
+    {
200
+      "id": "apple-watch-charger",
201
+      "name": "Apple Watch Charger",
202
+      "group": "Apple",
203
+      "category": "charger",
204
+      "icon": { "type": "systemSymbol", "name": "applewatch.radiowaves.left.and.right", "fallbackSystemName": "bolt.circle" },
205
+      "sortOrder": 220,
206
+      "capWiredCharging": false,
207
+      "capWirelessCharging": true,
208
+      "capWirelessProfiles": ["genericQi"],
209
+      "capChargingStateAvailability": "onOnly",
210
+      "capHasInternalSubject": false,
211
+      "defaultWirelessChargingProfile": "genericQi"
212
+    },
213
+    {
214
+      "id": "generic-magsafe-charger",
215
+      "name": "Generic MagSafe Charger",
216
+      "group": "Generic",
217
+      "category": "charger",
218
+      "icon": { "type": "systemSymbol", "name": "bolt.circle", "fallbackSystemName": "bolt.circle" },
219
+      "sortOrder": 230,
220
+      "capWiredCharging": false,
221
+      "capWirelessCharging": true,
222
+      "capWirelessProfiles": ["magsafe"],
223
+      "capChargingStateAvailability": "onOnly",
224
+      "capHasInternalSubject": false,
225
+      "defaultWirelessChargingProfile": "magsafe"
226
+    },
227
+    {
228
+      "id": "generic-qi-charger",
229
+      "name": "Generic Qi Charger",
230
+      "group": "Generic",
231
+      "category": "charger",
232
+      "icon": { "type": "systemSymbol", "name": "bolt.horizontal.circle", "fallbackSystemName": "bolt.horizontal.circle" },
233
+      "sortOrder": 240,
234
+      "capWiredCharging": false,
235
+      "capWirelessCharging": true,
236
+      "capWirelessProfiles": ["genericQi"],
237
+      "capChargingStateAvailability": "onOnly",
238
+      "capHasInternalSubject": false,
239
+      "defaultWirelessChargingProfile": "genericQi"
240
+    },
241
+    {
242
+      "id": "generic-powerbank",
243
+      "name": "Powerbank",
244
+      "group": "Generic",
245
+      "category": "powerbank",
246
+      "icon": { "type": "systemSymbol", "name": "battery.100.bolt", "fallbackSystemName": "battery.100.bolt" },
247
+      "sortOrder": 310,
248
+      "capWiredCharging": true,
249
+      "capWirelessCharging": false,
250
+      "capWirelessProfiles": [],
251
+      "capChargingStateAvailability": "offOnly",
252
+      "capHasInternalSubject": false,
253
+      "defaultWirelessChargingProfile": null
254
+    }
255
+  ]
256
+}
+432 -0
USB Meter/Views/ChargedDevices/ConsumptionMonitorView.swift
@@ -0,0 +1,432 @@
1
+//
2
+//  ConsumptionMonitorView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+import Charts
8
+
9
+// MARK: - Shared helpers (file-private)
10
+
11
+private func formattedDuration(_ duration: TimeInterval) -> String {
12
+    let formatter = DateComponentsFormatter()
13
+    formatter.allowedUnits = duration >= 3600 ? [.hour, .minute] : [.minute, .second]
14
+    formatter.unitsStyle = .abbreviated
15
+    formatter.zeroFormattingBehavior = .pad
16
+    return formatter.string(from: max(duration, 0)) ?? "0m"
17
+}
18
+
19
+private func energyLabel(_ wattHours: Double) -> String {
20
+    wattHours >= 1000
21
+        ? "\((wattHours / 1000).format(decimalDigits: 3)) kWh"
22
+        : "\(wattHours.format(decimalDigits: 2)) Wh"
23
+}
24
+
25
+@available(iOS 16, *)
26
+private func consumptionChart(samples: [ConsumptionMonitorSample], tint: Color) -> some View {
27
+    let duration = samples.last.map { $0.timestamp.timeIntervalSince(samples[0].timestamp) } ?? 0
28
+    return Chart(samples) { sample in
29
+        LineMark(
30
+            x: .value("Time", sample.timestamp),
31
+            y: .value("W", sample.averagePowerWatts)
32
+        )
33
+        .foregroundStyle(tint)
34
+        .interpolationMethod(.catmullRom)
35
+    }
36
+    .frame(height: 140)
37
+    .chartYScale(domain: .automatic(includesZero: false))
38
+    .chartXAxis {
39
+        if duration > 3600 {
40
+            AxisMarks(values: .stride(by: .hour)) { _ in
41
+                AxisGridLine()
42
+                AxisValueLabel(format: .dateTime.hour())
43
+            }
44
+        } else {
45
+            AxisMarks(values: .stride(by: .minute, count: 10)) { _ in
46
+                AxisGridLine()
47
+                AxisValueLabel(format: .dateTime.hour(.defaultDigits(amPM: .omitted)).minute())
48
+            }
49
+        }
50
+    }
51
+    .chartYAxis {
52
+        AxisMarks { value in
53
+            AxisGridLine()
54
+            AxisValueLabel {
55
+                if let v = value.as(Double.self) {
56
+                    Text("\(v.format(decimalDigits: 1)) W")
57
+                }
58
+            }
59
+        }
60
+    }
61
+}
62
+
63
+// MARK: - Main View
64
+
65
+struct ConsumptionMonitorView: View {
66
+    @EnvironmentObject private var appData: AppData
67
+
68
+    @State private var selectedMeterMACAddress: String?
69
+    @State private var selectedDeviceID: UUID?
70
+    @State private var discardConfirmationVisibility = false
71
+
72
+    let preferredMeterMACAddress: String?
73
+
74
+    init(preferredMeterMACAddress: String? = nil) {
75
+        self.preferredMeterMACAddress = preferredMeterMACAddress
76
+        _selectedMeterMACAddress = State(initialValue: preferredMeterMACAddress)
77
+    }
78
+
79
+    var body: some View {
80
+        ScrollView {
81
+            VStack(spacing: 18) {
82
+                if let session = activeSession {
83
+                    activeSessionCard(session)
84
+                    liveMetricsCard(session)
85
+                } else {
86
+                    setupCard
87
+                }
88
+                savedSessionsList
89
+            }
90
+            .padding()
91
+        }
92
+        .background(
93
+            LinearGradient(
94
+                colors: [.purple.opacity(0.16), Color.clear],
95
+                startPoint: .topLeading,
96
+                endPoint: .bottomTrailing
97
+            )
98
+            .ignoresSafeArea()
99
+        )
100
+        .navigationTitle("Consumption Monitor")
101
+        .navigationBarTitleDisplayMode(.inline)
102
+        .confirmationDialog(
103
+            "Stop and discard this session?",
104
+            isPresented: $discardConfirmationVisibility,
105
+            titleVisibility: .visible
106
+        ) {
107
+            Button("Discard", role: .destructive) {
108
+                if let session = activeSession {
109
+                    _ = appData.stopConsumptionMonitor(for: session.meterMACAddress, save: false)
110
+                }
111
+            }
112
+            Button("Cancel", role: .cancel) {}
113
+        } message: {
114
+            Text("The current session data will be lost and nothing will be saved.")
115
+        }
116
+    }
117
+
118
+    // MARK: - Computed
119
+
120
+    private var liveMeterSummaries: [AppData.MeterSummary] {
121
+        appData.meterSummaries.filter { $0.meter != nil }
122
+    }
123
+
124
+    private var availableDevices: [ChargedDeviceSummary] {
125
+        appData.deviceSummaries
126
+    }
127
+
128
+    private var activeSession: ConsumptionMonitorLiveSession? {
129
+        let candidates = [selectedMeterMACAddress, preferredMeterMACAddress].compactMap { $0 }
130
+        for mac in candidates {
131
+            if let session = appData.consumptionMonitorSession(for: mac) { return session }
132
+        }
133
+        for summary in liveMeterSummaries {
134
+            if let session = appData.consumptionMonitorSession(for: summary.macAddress) { return session }
135
+        }
136
+        return nil
137
+    }
138
+
139
+    private var selectedDevice: ChargedDeviceSummary? {
140
+        guard let id = selectedDeviceID else { return nil }
141
+        return availableDevices.first { $0.id == id }
142
+    }
143
+
144
+    private var selectedMeterSummary: AppData.MeterSummary? {
145
+        guard let mac = selectedMeterMACAddress else { return nil }
146
+        return liveMeterSummaries.first { $0.macAddress == mac }
147
+    }
148
+
149
+    private var savedSessions: [ConsumptionMonitorSessionSummary] {
150
+        guard let id = selectedDeviceID else { return [] }
151
+        return appData.chargedDeviceSummary(id: id)?.consumptionSessions.filter { !$0.isOpen } ?? []
152
+    }
153
+
154
+    private var canStart: Bool {
155
+        selectedDeviceID != nil && selectedMeterSummary != nil && activeSession == nil
156
+    }
157
+
158
+    // MARK: - Setup Card
159
+
160
+    private var setupCard: some View {
161
+        MeterInfoCardView(title: "New Session", tint: .purple) {
162
+            VStack(alignment: .leading, spacing: 12) {
163
+                if liveMeterSummaries.isEmpty {
164
+                    Text("Connect a live meter first to start a consumption monitor session.")
165
+                        .font(.footnote)
166
+                        .foregroundColor(.secondary)
167
+                } else {
168
+                    Text("Device")
169
+                        .font(.subheadline.weight(.semibold))
170
+
171
+                    if availableDevices.isEmpty {
172
+                        Text("No devices available. Add a device in the sidebar first.")
173
+                            .font(.caption)
174
+                            .foregroundColor(.secondary)
175
+                    } else {
176
+                        Picker("Device", selection: $selectedDeviceID) {
177
+                            Text("Select Device").tag(Optional<UUID>.none)
178
+                            ForEach(availableDevices) { device in
179
+                                Text(device.name).tag(Optional(device.id))
180
+                            }
181
+                        }
182
+                        .pickerStyle(.menu)
183
+                    }
184
+
185
+                    Text("Meter")
186
+                        .font(.subheadline.weight(.semibold))
187
+
188
+                    Picker("Meter", selection: $selectedMeterMACAddress) {
189
+                        Text("Select Meter").tag(Optional<String>.none)
190
+                        ForEach(liveMeterSummaries) { summary in
191
+                            Text(summary.displayName).tag(Optional(summary.macAddress))
192
+                        }
193
+                    }
194
+                    .pickerStyle(.menu)
195
+
196
+                    Button("Start Session") {
197
+                        startSession()
198
+                    }
199
+                    .disabled(!canStart)
200
+                    .buttonStyle(.borderedProminent)
201
+                    .tint(.purple)
202
+                }
203
+            }
204
+
205
+            if activeSession == nil, selectedDevice != nil, selectedMeterSummary == nil {
206
+                Text("Select a meter to begin.")
207
+                    .font(.caption)
208
+                    .foregroundColor(.secondary)
209
+            } else if activeSession == nil, selectedMeterSummary != nil, selectedDevice == nil {
210
+                Text("Select the device you want to monitor.")
211
+                    .font(.caption)
212
+                    .foregroundColor(.secondary)
213
+            } else if activeSession == nil, canStart {
214
+                Text("Samples are recorded every 60 seconds at a rate of 60 per hour.")
215
+                    .font(.caption)
216
+                    .foregroundColor(.secondary)
217
+            }
218
+        }
219
+    }
220
+
221
+    // MARK: - Active Session Card
222
+
223
+    private func activeSessionCard(_ session: ConsumptionMonitorLiveSession) -> some View {
224
+        MeterInfoCardView(
225
+            title: "Session Running",
226
+            infoMessage: "Samples are stored every 60 seconds. The session persists across app restarts until you stop it.",
227
+            tint: .purple
228
+        ) {
229
+            if let device = appData.chargedDeviceSummary(id: session.chargedDeviceID) {
230
+                MeterInfoRowView(label: "Device", value: device.name)
231
+            }
232
+            if let summary = liveMeterSummaries.first(where: { $0.macAddress == session.meterMACAddress }) {
233
+                MeterInfoRowView(label: "Meter", value: summary.displayName)
234
+            }
235
+            MeterInfoRowView(label: "Duration", value: formattedDuration(session.elapsedDuration))
236
+            MeterInfoRowView(label: "Samples", value: "\(session.committedSampleCount) × 60 s")
237
+            MeterInfoRowView(label: "Total Energy", value: energyLabel(session.cumulativeEnergyWh))
238
+
239
+            HStack(spacing: 12) {
240
+                Button("Save & Stop") {
241
+                    _ = appData.stopConsumptionMonitor(for: session.meterMACAddress, save: true)
242
+                }
243
+                .disabled(session.committedSampleCount == 0)
244
+
245
+                Button("Discard") {
246
+                    discardConfirmationVisibility = true
247
+                }
248
+                .foregroundColor(.red)
249
+            }
250
+            .buttonStyle(.borderedProminent)
251
+            .tint(.purple)
252
+        }
253
+    }
254
+
255
+    // MARK: - Live Metrics Card
256
+
257
+    private func liveMetricsCard(_ session: ConsumptionMonitorLiveSession) -> some View {
258
+        VStack(spacing: 18) {
259
+            MeterInfoCardView(title: "Live Reading", tint: .indigo) {
260
+                MeterInfoRowView(label: "Power", value: "\(session.currentPowerWatts.format(decimalDigits: 3)) W")
261
+                MeterInfoRowView(label: "Current", value: "\(session.currentCurrentAmps.format(decimalDigits: 3)) A")
262
+                MeterInfoRowView(label: "Voltage", value: "\(session.currentVoltageVolts.format(decimalDigits: 3)) V")
263
+            }
264
+
265
+            if session.committedSamples.count >= 2 {
266
+                liveChartCard(session.committedSamples)
267
+            }
268
+
269
+            if session.cumulativeEnergyWh > 0 {
270
+                projectionCard(
271
+                    averagePowerWatts: session.cumulativeEnergyWh / max(session.elapsedDuration / 3600, 0.001),
272
+                    totalEnergyWh: session.cumulativeEnergyWh
273
+                )
274
+            }
275
+        }
276
+    }
277
+
278
+    @ViewBuilder
279
+    private func liveChartCard(_ samples: [ConsumptionMonitorSample]) -> some View {
280
+        if #available(iOS 16, *) {
281
+            MeterInfoCardView(title: "Power Over Time", tint: .purple) {
282
+                consumptionChart(samples: samples, tint: .purple)
283
+            }
284
+        }
285
+    }
286
+
287
+    // MARK: - Projections Card
288
+
289
+    private func projectionCard(averagePowerWatts: Double, totalEnergyWh: Double) -> some View {
290
+        MeterInfoCardView(title: "Consumption Projection", tint: .teal) {
291
+            MeterInfoRowView(label: "Average Power", value: "\(averagePowerWatts.format(decimalDigits: 3)) W")
292
+            MeterInfoRowView(label: "24 Hours", value: energyLabel(averagePowerWatts * 24))
293
+            MeterInfoRowView(label: "7 Days", value: energyLabel(averagePowerWatts * 24 * 7))
294
+            MeterInfoRowView(label: "30 Days", value: energyLabel(averagePowerWatts * 24 * 30))
295
+            MeterInfoRowView(label: "1 Year", value: energyLabel(averagePowerWatts * 24 * 365))
296
+        }
297
+    }
298
+
299
+    // MARK: - Saved Sessions List
300
+
301
+    @ViewBuilder
302
+    private var savedSessionsList: some View {
303
+        if !savedSessions.isEmpty {
304
+            MeterInfoCardView(title: "Saved Sessions", tint: .purple) {
305
+                ForEach(savedSessions) { session in
306
+                    NavigationLink(destination: ConsumptionSessionDetailView(session: session)) {
307
+                        HStack {
308
+                            VStack(alignment: .leading, spacing: 2) {
309
+                                Text(session.startedAt, style: .date)
310
+                                    .font(.subheadline.weight(.semibold))
311
+                                Text("\(formattedDuration(session.duration)) · \(energyLabel(session.totalEnergyWh)) total")
312
+                                    .font(.caption)
313
+                                    .foregroundColor(.secondary)
314
+                            }
315
+                            Spacer()
316
+                            Image(systemName: "chevron.right")
317
+                                .font(.caption.weight(.semibold))
318
+                                .foregroundColor(.secondary)
319
+                        }
320
+                        .padding(.vertical, 4)
321
+                    }
322
+                    .buttonStyle(.plain)
323
+                }
324
+            }
325
+        }
326
+    }
327
+
328
+    // MARK: - Actions
329
+
330
+    private func startSession() {
331
+        guard let deviceID = selectedDeviceID,
332
+              let meterSummary = selectedMeterSummary,
333
+              let meter = meterSummary.meter else { return }
334
+        _ = appData.startConsumptionMonitor(for: deviceID, on: meter)
335
+    }
336
+}
337
+
338
+// MARK: - Session Detail
339
+
340
+struct ConsumptionSessionDetailView: View {
341
+    @EnvironmentObject private var appData: AppData
342
+
343
+    let session: ConsumptionMonitorSessionSummary
344
+
345
+    @State private var deleteConfirmationVisibility = false
346
+
347
+    var body: some View {
348
+        ScrollView {
349
+            VStack(spacing: 18) {
350
+                overviewCard
351
+                if session.averagePowerWatts > 0 {
352
+                    projectionCard
353
+                }
354
+                if session.samples.count >= 2 {
355
+                    chartCard
356
+                }
357
+                statsCard
358
+            }
359
+            .padding()
360
+        }
361
+        .background(
362
+            LinearGradient(
363
+                colors: [.purple.opacity(0.14), Color.clear],
364
+                startPoint: .topLeading,
365
+                endPoint: .bottomTrailing
366
+            )
367
+            .ignoresSafeArea()
368
+        )
369
+        .navigationTitle("Consumption Session")
370
+        .navigationBarTitleDisplayMode(.inline)
371
+        .toolbar {
372
+            ToolbarItem(placement: .destructiveAction) {
373
+                Button(role: .destructive) {
374
+                    deleteConfirmationVisibility = true
375
+                } label: {
376
+                    Image(systemName: "trash")
377
+                }
378
+            }
379
+        }
380
+        .confirmationDialog("Delete this session?", isPresented: $deleteConfirmationVisibility, titleVisibility: .visible) {
381
+            Button("Delete", role: .destructive) {
382
+                _ = appData.deleteConsumptionSession(id: session.id, deviceID: session.chargedDeviceID)
383
+            }
384
+            Button("Cancel", role: .cancel) {}
385
+        }
386
+    }
387
+
388
+    // MARK: - Cards
389
+
390
+    private var overviewCard: some View {
391
+        MeterInfoCardView(title: "Overview", tint: .purple) {
392
+            MeterInfoRowView(label: "Started", value: session.startedAt.formatted(date: .abbreviated, time: .shortened))
393
+            if let endedAt = session.endedAt {
394
+                MeterInfoRowView(label: "Ended", value: endedAt.formatted(date: .abbreviated, time: .shortened))
395
+            }
396
+            MeterInfoRowView(label: "Duration", value: formattedDuration(session.duration))
397
+            MeterInfoRowView(label: "Samples", value: "\(session.sampleCount) × 60 s")
398
+            MeterInfoRowView(label: "Total Energy", value: energyLabel(session.totalEnergyWh))
399
+            if let meterName = session.meterName {
400
+                MeterInfoRowView(label: "Meter", value: meterName)
401
+            }
402
+        }
403
+    }
404
+
405
+    private var projectionCard: some View {
406
+        MeterInfoCardView(title: "Consumption Projection", tint: .teal) {
407
+            MeterInfoRowView(label: "Average Power", value: "\(session.averagePowerWatts.format(decimalDigits: 3)) W")
408
+            MeterInfoRowView(label: "24 Hours", value: energyLabel(session.projectedDailyEnergyWh))
409
+            MeterInfoRowView(label: "7 Days", value: energyLabel(session.projectedWeeklyEnergyWh))
410
+            MeterInfoRowView(label: "30 Days", value: energyLabel(session.projectedMonthlyEnergyWh))
411
+            MeterInfoRowView(label: "1 Year", value: energyLabel(session.projectedYearlyEnergyWh))
412
+        }
413
+    }
414
+
415
+    private var statsCard: some View {
416
+        MeterInfoCardView(title: "Statistics", tint: .indigo) {
417
+            MeterInfoRowView(label: "Min Power", value: "\(session.minimumPowerWatts.format(decimalDigits: 3)) W")
418
+            MeterInfoRowView(label: "Max Power", value: "\(session.maximumPowerWatts.format(decimalDigits: 3)) W")
419
+            MeterInfoRowView(label: "Avg Current", value: "\(session.averageCurrentAmps.format(decimalDigits: 3)) A")
420
+            MeterInfoRowView(label: "Avg Voltage", value: "\(session.averageVoltageVolts.format(decimalDigits: 3)) V")
421
+        }
422
+    }
423
+
424
+    @ViewBuilder
425
+    private var chartCard: some View {
426
+        if #available(iOS 16, *) {
427
+            MeterInfoCardView(title: "Power Over Time", tint: .purple) {
428
+                consumptionChart(samples: session.samples, tint: .purple)
429
+            }
430
+        }
431
+    }
432
+}
+11 -14
USB Meter/Views/ChargedDevices/Details/ChargedDeviceDetailView.swift → USB Meter/Views/ChargedDevices/Details/ChargedDeviceSettingsView.swift
@@ -1,5 +1,5 @@
1 1
 //
2
-//  ChargedDeviceDetailView.swift
2
+//  ChargedDeviceSettingsView.swift
3 3
 //  USB Meter
4 4
 //
5 5
 //  Created by Codex on 10/04/2026.
@@ -7,7 +7,7 @@
7 7
 
8 8
 import SwiftUI
9 9
 
10
-struct ChargedDeviceDetailView: View {
10
+struct ChargedDeviceSettingsView: View {
11 11
     private enum DetailTab: Hashable {
12 12
         case overview
13 13
         case standby
@@ -33,22 +33,22 @@ struct ChargedDeviceDetailView: View {
33 33
             if let chargedDevice = appData.chargedDeviceSummary(id: chargedDeviceID) {
34 34
                 tabbedDetailView(chargedDevice)
35 35
                 .navigationTitle(chargedDevice.name)
36
+                .navigationBarTitleDisplayMode(.inline)
36 37
             } else {
37 38
                 Text("This device is no longer available.")
38 39
                     .foregroundColor(.secondary)
39 40
                     .navigationTitle("Device")
41
+                    .navigationBarTitleDisplayMode(.inline)
40 42
             }
41 43
         }
44
+        .sidebarToggleToolbarItem()
42 45
         .sheet(isPresented: $editorVisibility) {
43 46
             if let chargedDevice = appData.chargedDeviceSummary(id: chargedDeviceID) {
44 47
                 if chargedDevice.isCharger {
45 48
                     ChargerEditorSheetView(chargedDevice: chargedDevice)
46 49
                         .environmentObject(appData)
47 50
                 } else {
48
-                    ChargedDeviceEditorSheetView(
49
-                        meterMACAddress: nil,
50
-                        chargedDevice: chargedDevice
51
-                    )
51
+                    ChargedDeviceEditorSheetView(chargedDevice: chargedDevice)
52 52
                     .environmentObject(appData)
53 53
                 }
54 54
             }
@@ -333,12 +333,6 @@ struct ChargedDeviceDetailView: View {
333 333
                     .font(.subheadline.weight(.semibold))
334 334
                     .foregroundColor(.secondary)
335 335
 
336
-                if let meterMAC = chargedDevice.lastAssociatedMeterMAC {
337
-                    Text("Default meter: \(meterMAC)")
338
-                        .font(.caption)
339
-                        .foregroundColor(.secondary)
340
-                }
341
-
342 336
                 Text(chargedDevice.qrIdentifier)
343 337
                     .font(.caption2.monospaced())
344 338
                     .foregroundColor(.secondary)
@@ -361,8 +355,11 @@ struct ChargedDeviceDetailView: View {
361 355
             MeterInfoRowView(label: "Template", value: chargedDevice.identityTitle)
362 356
             MeterInfoRowView(label: "QR ID", value: chargedDevice.qrIdentifier)
363 357
 
364
-            if let meterMAC = chargedDevice.lastAssociatedMeterMAC {
365
-                MeterInfoRowView(label: "Default Meter", value: meterMAC)
358
+            if chargedDevice.supportsInternalSubject {
359
+                MeterInfoRowView(
360
+                    label: "Subject",
361
+                    value: chargedDevice.hasInternalSubject ? "Inside" : "Empty"
362
+                )
366 363
             }
367 364
 
368 365
             MeterInfoRowView(label: "Created", value: chargedDevice.createdAt.format())
+803 -174
USB Meter/Views/ChargedDevices/Sessions/ChargeSessionDetailView.swift
@@ -35,6 +35,12 @@ struct ChargeSessionDetailView: View {
35 35
         }
36 36
     }
37 37
 
38
+    private struct BatteryPercentCandidate {
39
+        let timestamp: Date
40
+        let percent: Double
41
+        let isCheckpoint: Bool
42
+    }
43
+
38 44
     @EnvironmentObject private var appData: AppData
39 45
 
40 46
     let chargedDeviceID: UUID
@@ -45,12 +51,14 @@ struct ChargeSessionDetailView: View {
45 51
     @State private var pendingCheckpointDeletion: ChargeCheckpointSummary?
46 52
     @State private var pendingSessionDeletion: ChargeSessionSummary?
47 53
     @State private var pendingSessionStopRequest: ChargeSessionStopRequest?
54
+    @State private var pendingTrimCommitSession: ChargeSessionSummary?
48 55
     @State private var detectedTrimWindow: ChargingWindowDetector.DetectedWindow?
49 56
     @State private var trimBannerDismissedForSessionID: UUID?
50 57
     @State private var showingInlineTargetEditor = false
51 58
     @State private var draftTargetText = ""
52 59
     @State private var showingStopConfirm = false
53 60
     @State private var finalCheckpointMode: FinalCheckpoint = .skip
61
+    @State private var isBatteryCardExpanded = false
54 62
     @State private var finalCheckpointText = ""
55 63
     @State private var stopFailureMessage: String?
56 64
 
@@ -71,7 +79,15 @@ struct ChargeSessionDetailView: View {
71 79
     }
72 80
 
73 81
     private var session: ChargeSessionSummary? {
74
-        chargedDevice?.sessions.first(where: { $0.id == sessionID })
82
+        appData.chargeSessionSummary(id: sessionID)
83
+            ?? chargedDevice?.sessions.first(where: { $0.id == sessionID })
84
+    }
85
+
86
+    private var chargedPowerbank: PowerbankSummary? {
87
+        guard let powerbankID = session?.chargedPowerbankID else {
88
+            return nil
89
+        }
90
+        return appData.powerbankSummaries.first { $0.id == powerbankID }
75 91
     }
76 92
 
77 93
     private var liveMonitoringMeter: Meter? {
@@ -110,6 +126,8 @@ struct ChargeSessionDetailView: View {
110 126
         Group {
111 127
             if let chargedDevice, let session {
112 128
                 content(chargedDevice: chargedDevice, session: session)
129
+            } else if let chargedPowerbank, let session {
130
+                powerbankContent(powerbank: chargedPowerbank, session: session)
113 131
             } else {
114 132
                 unavailableState
115 133
             }
@@ -150,18 +168,30 @@ struct ChargeSessionDetailView: View {
150 168
                 secondaryButton: .cancel()
151 169
             )
152 170
         }
171
+        .alert(item: $pendingTrimCommitSession) { session in
172
+            Alert(
173
+                title: Text("Save Trim Permanently?"),
174
+                message: Text("Samples and checkpoints outside \(session.effectiveTrimStart.format()) - \(session.effectiveTrimEnd.format()) will be deleted. Reset Trim will no longer restore them."),
175
+                primaryButton: .destructive(Text("Save Trim")) {
176
+                    _ = appData.commitSessionTrim(sessionID: session.id)
177
+                },
178
+                secondaryButton: .cancel()
179
+            )
180
+        }
153 181
         .onAppear {
154 182
             syncMonitoringRestore()
155 183
             runTrimDetection()
156 184
         }
157 185
         .onChange(of: session?.id) { _ in
158 186
             pendingSessionStopRequest = nil
187
+            pendingTrimCommitSession = nil
159 188
             detectedTrimWindow = nil
160 189
             trimBannerDismissedForSessionID = nil
161 190
             showingInlineTargetEditor = false
162 191
             draftTargetText = ""
163 192
             showingStopConfirm = false
164 193
             finalCheckpointMode = .skip
194
+            isBatteryCardExpanded = false
165 195
             finalCheckpointText = ""
166 196
             stopFailureMessage = nil
167 197
             syncMonitoringRestore()
@@ -171,6 +201,9 @@ struct ChargeSessionDetailView: View {
171 201
             syncMonitoringRestore()
172 202
             runTrimDetection()
173 203
         }
204
+        .onChange(of: session?.checkpoints.count) { _ in
205
+            syncMonitoringRestore()
206
+        }
174 207
         .onChange(of: finalCheckpointMode) { _ in
175 208
             stopFailureMessage = nil
176 209
         }
@@ -193,22 +226,18 @@ struct ChargeSessionDetailView: View {
193 226
                     }
194 227
 
195 228
                     if shouldShowSessionChart(session) {
196
-                        chartCard(session)
229
+                        chartCard(session, chargedDevice: chargedDevice)
197 230
                     }
198 231
                 } else {
199 232
                     overviewCard(session, chargedDevice: chargedDevice)
200
-                    energyCard(session, chargedDevice: chargedDevice)
201
-                    observedMetricsCard(session, chargedDevice: chargedDevice)
202 233
                     batteryCard(session, chargedDevice: chargedDevice)
203 234
 
204 235
                     if shouldShowSessionChart(session) {
205
-                        chartCard(session)
236
+                        chartCard(session, chargedDevice: chargedDevice)
206 237
                     }
207 238
 
208 239
                     if session.status.isOpen {
209 240
                         followerNoticeCard(session)
210
-                    } else {
211
-                        managementCard(session)
212 241
                     }
213 242
                 }
214 243
             }
@@ -223,6 +252,19 @@ struct ChargeSessionDetailView: View {
223 252
             .ignoresSafeArea()
224 253
         )
225 254
         .navigationTitle(session.status.isOpen ? "Current Session" : "Session Details")
255
+        .navigationBarTitleDisplayMode(.inline)
256
+        .toolbar {
257
+            ToolbarItemGroup(placement: .primaryAction) {
258
+                if session.status.isOpen == false {
259
+                    Button(role: .destructive) {
260
+                        pendingSessionDeletion = session
261
+                    } label: {
262
+                        Image(systemName: "trash")
263
+                    }
264
+                    .help("Delete session")
265
+                }
266
+            }
267
+        }
226 268
     }
227 269
 
228 270
     private var unavailableState: some View {
@@ -240,6 +282,105 @@ struct ChargeSessionDetailView: View {
240 282
         .frame(maxWidth: .infinity, maxHeight: .infinity)
241 283
         .padding(24)
242 284
         .navigationTitle("Session")
285
+        .navigationBarTitleDisplayMode(.inline)
286
+    }
287
+
288
+    private func powerbankContent(
289
+        powerbank: PowerbankSummary,
290
+        session: ChargeSessionSummary
291
+    ) -> some View {
292
+        ScrollView {
293
+            VStack(spacing: 16) {
294
+                powerbankSessionCard(powerbank: powerbank, session: session)
295
+
296
+                if shouldShowSessionChart(session) {
297
+                    powerbankChartCard(session)
298
+                }
299
+
300
+                if session.status.isOpen && !hasMonitoringControls {
301
+                    followerNoticeCard(session)
302
+                }
303
+            }
304
+            .padding(presentation == .embedded ? 16 : 20)
305
+        }
306
+        .background(
307
+            LinearGradient(
308
+                colors: [statusTint(for: session).opacity(0.14), Color.clear],
309
+                startPoint: .topLeading,
310
+                endPoint: .bottomTrailing
311
+            )
312
+            .ignoresSafeArea()
313
+        )
314
+        .navigationTitle(session.status.isOpen ? "Current Session" : "Session Details")
315
+        .navigationBarTitleDisplayMode(.inline)
316
+        .toolbar {
317
+            ToolbarItemGroup(placement: .primaryAction) {
318
+                if session.status.isOpen == false {
319
+                    Button(role: .destructive) {
320
+                        pendingSessionDeletion = session
321
+                    } label: {
322
+                        Image(systemName: "trash")
323
+                    }
324
+                    .help("Delete session")
325
+                }
326
+            }
327
+        }
328
+    }
329
+
330
+    private func powerbankSessionCard(
331
+        powerbank: PowerbankSummary,
332
+        session: ChargeSessionSummary
333
+    ) -> some View {
334
+        let displayedEnergyWh = displayedSessionEnergyWh(for: session)
335
+
336
+        return VStack(alignment: .leading, spacing: 14) {
337
+            HStack {
338
+                Label(powerbank.name, systemImage: powerbank.identitySymbolName)
339
+                    .font(.headline)
340
+
341
+                Spacer()
342
+
343
+                Text(session.status.title)
344
+                    .font(.caption.weight(.bold))
345
+                    .foregroundColor(monitoringStatusColor(for: session))
346
+                    .padding(.horizontal, 8)
347
+                    .padding(.vertical, 4)
348
+                    .meterCard(tint: monitoringStatusColor(for: session), fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
349
+            }
350
+
351
+            LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
352
+                metricCell(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh", tint: .blue)
353
+                metricCell(label: "Duration", value: formatDuration(displayedSessionDuration(for: session)), tint: .teal)
354
+                metricCell(label: "Type", value: session.chargingTransportMode.title, tint: .orange)
355
+                metricCell(label: "Auto Stop", value: autoStopLabel(for: session), tint: .secondary)
356
+            }
357
+
358
+            if let sourcePowerbankID = session.sourcePowerbankID,
359
+               let sourcePowerbank = appData.powerbankSummaries.first(where: { $0.id == sourcePowerbankID }) {
360
+                MeterInfoRowView(label: "Source Powerbank", value: sourcePowerbank.name)
361
+            }
362
+
363
+            BatteryCheckpointSectionView(
364
+                sessionID: session.id,
365
+                checkpoints: session.checkpoints,
366
+                message: "Checkpoints are stored on the powerbank charge session and help estimate received capacity.",
367
+                canAddCheckpoint: hasMonitoringControls && appData.canAddBatteryCheckpoint(to: session.id),
368
+                canDeleteCheckpoint: hasMonitoringControls || session.status.isOpen == false,
369
+                requirementMessage: session.status.isOpen ? appData.batteryCheckpointCaptureRequirementMessage(for: session.id) : nil,
370
+                effectiveEnergyWhOverride: displayedEnergyWh,
371
+                onDelete: { checkpoint in
372
+                    pendingCheckpointDeletion = checkpoint
373
+                }
374
+            )
375
+
376
+            if showingStopConfirm {
377
+                stopConfirmPanel(session: session, displayedEnergyWh: displayedEnergyWh)
378
+            } else if hasMonitoringControls {
379
+                monitoringActionRow(session)
380
+            }
381
+        }
382
+        .padding(18)
383
+        .meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20)
243 384
     }
244 385
 
245 386
     private func monitoringSessionCard(
@@ -247,7 +388,6 @@ struct ChargeSessionDetailView: View {
247 388
         chargedDevice: ChargedDeviceSummary
248 389
     ) -> some View {
249 390
         let displayedEnergyWh = displayedSessionEnergyWh(for: session)
250
-        let displayedChargeAh = displayedSessionChargeAh(for: session)
251 391
         let batteryPrediction = chargedDevice.batteryLevelPrediction(
252 392
             for: session,
253 393
             effectiveEnergyWhOverride: displayedEnergyWh
@@ -316,7 +456,6 @@ struct ChargeSessionDetailView: View {
316 456
                 canDeleteCheckpoint: true,
317 457
                 requirementMessage: appData.batteryCheckpointCaptureRequirementMessage(for: session.id),
318 458
                 effectiveEnergyWhOverride: displayedEnergyWh,
319
-                measuredChargeAhOverride: displayedChargeAh,
320 459
                 onDelete: { checkpoint in
321 460
                     pendingCheckpointDeletion = checkpoint
322 461
                 }
@@ -330,8 +469,7 @@ struct ChargeSessionDetailView: View {
330 469
             if showingStopConfirm {
331 470
                 stopConfirmPanel(
332 471
                     session: session,
333
-                    displayedEnergyWh: displayedEnergyWh,
334
-                    displayedChargeAh: displayedChargeAh
472
+                    displayedEnergyWh: displayedEnergyWh
335 473
                 )
336 474
             } else {
337 475
                 monitoringActionRow(session)
@@ -345,97 +483,139 @@ struct ChargeSessionDetailView: View {
345 483
         _ session: ChargeSessionSummary,
346 484
         chargedDevice: ChargedDeviceSummary
347 485
     ) -> some View {
348
-        MeterInfoCardView(title: session.status.isOpen ? "Open Session" : "Overview", tint: statusTint(for: session)) {
349
-            MeterInfoRowView(label: "Device", value: chargedDevice.name)
350
-            MeterInfoRowView(label: "Status", value: session.status.title)
351
-            MeterInfoRowView(label: "Started", value: session.startedAt.format())
352
-            if let endedAt = session.endedAt {
353
-                MeterInfoRowView(label: "Ended", value: endedAt.format())
354
-            }
355
-            MeterInfoRowView(label: "Duration", value: sessionDurationText(session))
356
-            MeterInfoRowView(label: "Charging Type", value: session.chargingTransportMode.title)
357
-            MeterInfoRowView(label: "Charging Mode", value: session.chargingStateMode.title)
358
-            MeterInfoRowView(label: "Source", value: session.sourceMode.title)
359
-            MeterInfoRowView(label: "Auto Stop", value: autoStopDescription(for: session))
360
-            if session.isTrimmed {
361
-                MeterInfoRowView(label: "Trim Start", value: session.effectiveTrimStart.format())
362
-                MeterInfoRowView(label: "Trim End", value: session.effectiveTrimEnd.format())
363
-            }
364
-            if let meterName = session.meterName {
365
-                MeterInfoRowView(label: "Meter", value: meterName)
366
-            } else if let meterMACAddress = session.meterMACAddress {
367
-                MeterInfoRowView(label: "Meter", value: meterMACAddress)
368
-            }
369
-            if let meterModel = session.meterModel {
370
-                MeterInfoRowView(label: "Meter Model", value: meterModel)
486
+        MeterInfoCardView(
487
+            title: session.status.isOpen ? "Open Session" : "Overview",
488
+            tint: statusTint(for: session),
489
+            isCollapsible: true,
490
+            initiallyExpanded: false,
491
+            trailingActions: {
492
+                HStack(spacing: 4) {
493
+                    Text(session.startedAt, style: .time)
494
+                        .font(.caption2)
495
+                        .foregroundColor(.secondary)
496
+                        .monospacedDigit()
497
+                    Text("·")
498
+                        .font(.caption2)
499
+                        .foregroundColor(.secondary)
500
+                    Text(sessionDurationText(session))
501
+                        .font(.caption2.weight(.semibold))
502
+                        .foregroundColor(.secondary)
503
+                        .monospacedDigit()
504
+                }
371 505
             }
372
-        }
373
-    }
506
+        ) {
507
+            VStack(alignment: .leading, spacing: 10) {
508
+                MeterInfoRowView(label: "Device", value: chargedDevice.name)
374 509
 
375
-    private func energyCard(
376
-        _ session: ChargeSessionSummary,
377
-        chargedDevice: ChargedDeviceSummary
378
-    ) -> some View {
379
-        let displayedEnergyWh = displayedSessionEnergyWh(for: session)
380
-        let displayedChargeAh = displayedSessionChargeAh(for: session)
510
+                Divider()
381 511
 
382
-        return MeterInfoCardView(title: "Energy", tint: .teal) {
383
-            MeterInfoRowView(label: "Battery Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh")
384
-            if abs(displayedEnergyWh - session.measuredEnergyWh) > 0.01 {
385
-                MeterInfoRowView(label: "Measured Energy", value: "\(session.measuredEnergyWh.format(decimalDigits: 3)) Wh")
386
-            }
387
-            MeterInfoRowView(label: "Measured Charge", value: "\(displayedChargeAh.format(decimalDigits: 3)) Ah")
388
-            if let effectiveBatteryEnergyWh = session.effectiveBatteryEnergyWh,
389
-               abs(effectiveBatteryEnergyWh - session.measuredEnergyWh) > 0.01 {
390
-                MeterInfoRowView(label: "Effective Battery Energy", value: "\(effectiveBatteryEnergyWh.format(decimalDigits: 3)) Wh")
391
-            }
392
-            if let capacityEstimateWh = session.capacityEstimateWh {
393
-                MeterInfoRowView(label: "Capacity Estimate", value: "\(capacityEstimateWh.format(decimalDigits: 2)) Wh")
394
-            }
395
-            if let chargerID = session.chargerID,
396
-               let charger = appData.chargedDeviceSummary(id: chargerID) {
397
-                MeterInfoRowView(label: chargedDevice.hasMultipleChargingTransports ? "Wireless Charger" : "Charger", value: charger.name)
398
-            }
399
-            if let wirelessSessionHint = wirelessSessionHint(for: session) {
400
-                Text(wirelessSessionHint)
401
-                    .font(.caption2)
402
-                    .foregroundColor(session.shouldWarnAboutLowWirelessEfficiency ? .orange : .secondary)
403
-            }
404
-            if let sessionWarning = sessionWarning(for: session) {
405
-                Label(sessionWarning, systemImage: "exclamationmark.triangle")
406
-                    .font(.caption2)
407
-                    .foregroundColor(.orange)
512
+                HStack(alignment: .top, spacing: 12) {
513
+                    overviewStatCell(label: "Started", value: session.startedAt.format())
514
+                    if let endedAt = session.endedAt {
515
+                        overviewStatCell(label: "Ended", value: endedAt.format())
516
+                    }
517
+                }
518
+
519
+                HStack(alignment: .top, spacing: 12) {
520
+                    overviewStatCell(label: "Duration", value: sessionDurationText(session))
521
+                    overviewStatCell(label: "Status", value: session.status.title)
522
+                }
523
+
524
+                Divider()
525
+
526
+                HStack(alignment: .top, spacing: 12) {
527
+                    overviewStatCell(label: "Charging Type", value: session.chargingTransportMode.title)
528
+                    overviewStatCell(label: "Charging Mode", value: session.chargingStateMode.title)
529
+                }
530
+
531
+                HStack(alignment: .top, spacing: 12) {
532
+                    overviewStatCell(label: "Source", value: session.sourceMode.title)
533
+                    overviewStatCell(label: "Auto Stop", value: autoStopDescription(for: session))
534
+                }
535
+
536
+                if session.isTrimmed {
537
+                    Divider()
538
+                    HStack(alignment: .top, spacing: 12) {
539
+                        overviewStatCell(label: "Trim Start", value: session.effectiveTrimStart.format())
540
+                        overviewStatCell(label: "Trim End", value: session.effectiveTrimEnd.format())
541
+                    }
542
+                }
543
+
544
+                let meterLabel: String? = session.meterName ?? session.meterMACAddress
545
+                if meterLabel != nil || session.meterModel != nil {
546
+                    Divider()
547
+                    HStack(alignment: .top, spacing: 12) {
548
+                        if let label = meterLabel {
549
+                            overviewStatCell(label: "Meter", value: label)
550
+                        }
551
+                        if let model = session.meterModel {
552
+                            overviewStatCell(label: "Meter Model", value: model)
553
+                        }
554
+                    }
555
+                }
556
+
557
+                if session.minimumObservedCurrentAmps != nil
558
+                    || session.maximumObservedCurrentAmps != nil
559
+                    || session.maximumObservedPowerWatts != nil
560
+                    || session.maximumObservedVoltageVolts != nil
561
+                    || (chargedDevice.isCharger && session.selectedSourceVoltageVolts != nil)
562
+                    || session.completionCurrentAmps != nil
563
+                    || session.selectedDataGroup != nil {
564
+
565
+                    Divider()
566
+
567
+                    if session.minimumObservedCurrentAmps != nil || session.maximumObservedCurrentAmps != nil {
568
+                        HStack(alignment: .top, spacing: 12) {
569
+                            if let v = session.minimumObservedCurrentAmps {
570
+                                overviewStatCell(label: "Min Current", value: "\(v.format(decimalDigits: 2)) A")
571
+                            }
572
+                            if let v = session.maximumObservedCurrentAmps {
573
+                                overviewStatCell(label: "Max Current", value: "\(v.format(decimalDigits: 2)) A")
574
+                            }
575
+                        }
576
+                    }
577
+
578
+                    if session.maximumObservedPowerWatts != nil || session.maximumObservedVoltageVolts != nil {
579
+                        HStack(alignment: .top, spacing: 12) {
580
+                            if let v = session.maximumObservedPowerWatts {
581
+                                overviewStatCell(label: "Max Power", value: "\(v.format(decimalDigits: 2)) W")
582
+                            }
583
+                            if let v = session.maximumObservedVoltageVolts {
584
+                                overviewStatCell(label: "Max Voltage", value: "\(v.format(decimalDigits: 2)) V")
585
+                            }
586
+                        }
587
+                    }
588
+
589
+                    if session.completionCurrentAmps != nil
590
+                        || (chargedDevice.isCharger && session.selectedSourceVoltageVolts != nil) {
591
+                        HStack(alignment: .top, spacing: 12) {
592
+                            if let v = session.completionCurrentAmps {
593
+                                overviewStatCell(label: "Completion Current", value: "\(v.format(decimalDigits: 2)) A")
594
+                            }
595
+                            if chargedDevice.isCharger, let v = session.selectedSourceVoltageVolts {
596
+                                overviewStatCell(label: "Selected Voltage", value: "\(v.format(decimalDigits: 2)) V")
597
+                            }
598
+                        }
599
+                    }
600
+
601
+                    if let dg = session.selectedDataGroup {
602
+                        MeterInfoRowView(label: "Data Group", value: "\(dg)")
603
+                    }
604
+                }
408 605
             }
409 606
         }
410 607
     }
411 608
 
412
-    private func observedMetricsCard(
413
-        _ session: ChargeSessionSummary,
414
-        chargedDevice: ChargedDeviceSummary
415
-    ) -> some View {
416
-        MeterInfoCardView(title: "Observed Metrics", tint: .blue) {
417
-            if let minimumObservedCurrentAmps = session.minimumObservedCurrentAmps {
418
-                MeterInfoRowView(label: "Min Current", value: "\(minimumObservedCurrentAmps.format(decimalDigits: 2)) A")
419
-            }
420
-            if let maximumObservedCurrentAmps = session.maximumObservedCurrentAmps {
421
-                MeterInfoRowView(label: "Max Current", value: "\(maximumObservedCurrentAmps.format(decimalDigits: 2)) A")
422
-            }
423
-            if let maximumObservedPowerWatts = session.maximumObservedPowerWatts {
424
-                MeterInfoRowView(label: "Max Power", value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W")
425
-            }
426
-            if let maximumObservedVoltageVolts = session.maximumObservedVoltageVolts {
427
-                MeterInfoRowView(label: "Max Voltage", value: "\(maximumObservedVoltageVolts.format(decimalDigits: 2)) V")
428
-            }
429
-            if chargedDevice.isCharger, let selectedSourceVoltageVolts = session.selectedSourceVoltageVolts {
430
-                MeterInfoRowView(label: "Selected Voltage", value: "\(selectedSourceVoltageVolts.format(decimalDigits: 2)) V")
431
-            }
432
-            if let completionCurrentAmps = session.completionCurrentAmps {
433
-                MeterInfoRowView(label: "Completion Current", value: "\(completionCurrentAmps.format(decimalDigits: 2)) A")
434
-            }
435
-            if session.selectedDataGroup != nil {
436
-                MeterInfoRowView(label: "Data Group", value: "\(session.selectedDataGroup ?? 0)")
437
-            }
609
+    private func overviewStatCell(label: String, value: String) -> some View {
610
+        VStack(alignment: .leading, spacing: 2) {
611
+            Text(label)
612
+                .font(.caption2)
613
+                .foregroundColor(.secondary)
614
+            Text(value)
615
+                .font(.footnote.weight(.medium))
616
+                .monospacedDigit()
438 617
         }
618
+        .frame(maxWidth: .infinity, alignment: .leading)
439 619
     }
440 620
 
441 621
     private func batteryCard(
@@ -443,52 +623,193 @@ struct ChargeSessionDetailView: View {
443 623
         chargedDevice: ChargedDeviceSummary
444 624
     ) -> some View {
445 625
         let displayedEnergyWh = displayedSessionEnergyWh(for: session)
446
-        let displayedChargeAh = displayedSessionChargeAh(for: session)
447 626
         let batteryPrediction = chargedDevice.batteryLevelPrediction(
448 627
             for: session,
449 628
             effectiveEnergyWhOverride: hasMonitoringControls ? displayedEnergyWh : nil
450 629
         )
451 630
 
452
-        return MeterInfoCardView(title: "Battery", tint: .orange) {
453
-            if let startBatteryPercent = session.startBatteryPercent {
454
-                MeterInfoRowView(label: "Start Battery", value: "\(startBatteryPercent.format(decimalDigits: 0))%")
631
+        let startPercent = session.startBatteryPercent
632
+        let endPercent = session.endBatteryPercent ?? batteryPrediction?.predictedPercent
633
+        let isEstimatedEnd = session.endBatteryPercent == nil && batteryPrediction != nil
634
+        let showsPreview = startPercent != nil && endPercent != nil
635
+
636
+        return VStack(alignment: .leading, spacing: 0) {
637
+
638
+            // Header — always visible, tappable
639
+            HStack(spacing: 8) {
640
+                Text("Battery")
641
+                    .font(.headline)
642
+                Spacer(minLength: 0)
643
+                Image(systemName: "chevron.up")
644
+                    .font(.caption.weight(.semibold))
645
+                    .foregroundColor(.secondary)
646
+                    .rotationEffect(.degrees(isBatteryCardExpanded ? 0 : -180))
647
+                    .animation(.easeInOut(duration: 0.2), value: isBatteryCardExpanded)
455 648
             }
456
-            if let endBatteryPercent = session.endBatteryPercent {
457
-                MeterInfoRowView(label: "End Battery", value: "\(endBatteryPercent.format(decimalDigits: 0))%")
649
+            .contentShape(Rectangle())
650
+            .onTapGesture {
651
+                withAnimation(.easeInOut(duration: 0.25)) {
652
+                    isBatteryCardExpanded.toggle()
653
+                }
458 654
             }
459
-            if let batteryDeltaPercent = session.batteryDeltaPercent {
460
-                MeterInfoRowView(label: "Battery Delta", value: "\(batteryDeltaPercent.format(decimalDigits: 0))%")
655
+
656
+            // Preview bar — always visible when there is enough data
657
+            if showsPreview, let start = startPercent, let end = endPercent {
658
+                batteryPreviewBar(
659
+                    startPercent: start,
660
+                    endPercent: end,
661
+                    checkpoints: session.checkpoints,
662
+                    isEstimatedEnd: isEstimatedEnd
663
+                )
664
+                .padding(.top, 10)
461 665
             }
462
-            if let targetBatteryPercent = session.targetBatteryPercent {
463
-                MeterInfoRowView(label: "Target Notification", value: "\(targetBatteryPercent.format(decimalDigits: 0))%")
666
+
667
+            // Collapsible detail
668
+            if isBatteryCardExpanded {
669
+                VStack(alignment: .leading, spacing: 10) {
670
+
671
+                    // Energy
672
+                    HStack(alignment: .top, spacing: 12) {
673
+                        overviewStatCell(label: "Battery Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh")
674
+                        if let capacityEstimateWh = session.capacityEstimateWh {
675
+                            overviewStatCell(label: "Capacity Estimate", value: "\(capacityEstimateWh.format(decimalDigits: 2)) Wh")
676
+                        }
677
+                    }
678
+                    if abs(displayedEnergyWh - session.measuredEnergyWh) > 0.01 {
679
+                        MeterInfoRowView(label: "Measured Energy", value: "\(session.measuredEnergyWh.format(decimalDigits: 3)) Wh")
680
+                    }
681
+                    if let effectiveBatteryEnergyWh = session.effectiveBatteryEnergyWh,
682
+                       abs(effectiveBatteryEnergyWh - session.measuredEnergyWh) > 0.01 {
683
+                        MeterInfoRowView(label: "Effective Battery Energy", value: "\(effectiveBatteryEnergyWh.format(decimalDigits: 3)) Wh")
684
+                    }
685
+                    if let chargerID = session.chargerID,
686
+                       let charger = appData.chargedDeviceSummary(id: chargerID) {
687
+                        MeterInfoRowView(label: chargedDevice.hasMultipleChargingTransports ? "Wireless Charger" : "Charger", value: charger.name)
688
+                    }
689
+                    if let wirelessSessionHint = wirelessSessionHint(for: session) {
690
+                        Text(wirelessSessionHint)
691
+                            .font(.caption2)
692
+                            .foregroundColor(session.shouldWarnAboutLowWirelessEfficiency ? .orange : .secondary)
693
+                    }
694
+                    if let sessionWarning = sessionWarning(for: session) {
695
+                        Label(sessionWarning, systemImage: "exclamationmark.triangle")
696
+                            .font(.caption2)
697
+                            .foregroundColor(.orange)
698
+                    }
699
+
700
+                    // Battery percentages
701
+                    if startPercent != nil || session.endBatteryPercent != nil {
702
+                        Divider()
703
+                        HStack(alignment: .top, spacing: 12) {
704
+                            if let v = startPercent {
705
+                                overviewStatCell(
706
+                                    label: "Start Battery",
707
+                                    value: session.startsFromFlatBattery ? "Flat" : "\(v.format(decimalDigits: 0))%"
708
+                                )
709
+                            }
710
+                            if let v = session.endBatteryPercent {
711
+                                overviewStatCell(label: "End Battery", value: "\(v.format(decimalDigits: 0))%")
712
+                            }
713
+                        }
714
+                        if session.batteryDeltaPercent != nil || session.targetBatteryPercent != nil {
715
+                            HStack(alignment: .top, spacing: 12) {
716
+                                if let v = session.batteryDeltaPercent {
717
+                                    overviewStatCell(label: "Battery Delta", value: "\(v.format(decimalDigits: 0))%")
718
+                                }
719
+                                if let v = session.targetBatteryPercent {
720
+                                    overviewStatCell(label: "Target", value: "\(v.format(decimalDigits: 0))%")
721
+                                }
722
+                            }
723
+                        }
724
+                        if let batteryPrediction {
725
+                            HStack(alignment: .top, spacing: 12) {
726
+                                overviewStatCell(label: "Predicted Battery", value: "\(batteryPrediction.predictedPercent.format(decimalDigits: 0))%")
727
+                            }
728
+                            Text(batteryPredictionExplanation(batteryPrediction))
729
+                            .font(.caption2)
730
+                            .foregroundColor(.secondary)
731
+                        }
732
+                    }
733
+
734
+                    // Checkpoints
735
+                    Divider()
736
+                    BatteryCheckpointSectionView(
737
+                        sessionID: session.id,
738
+                        checkpoints: session.checkpoints,
739
+                        message: session.status.isOpen
740
+                            ? "The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction."
741
+                            : "These checkpoints were saved with this closed session and feed capacity estimation and charge-level prediction.",
742
+                        canAddCheckpoint: hasMonitoringControls && appData.canAddBatteryCheckpoint(to: session.id),
743
+                        canDeleteCheckpoint: hasMonitoringControls || session.status.isOpen == false,
744
+                        requirementMessage: session.status.isOpen ? appData.batteryCheckpointCaptureRequirementMessage(for: session.id) : nil,
745
+                        effectiveEnergyWhOverride: hasMonitoringControls ? displayedEnergyWh : nil,
746
+                        onDelete: { checkpoint in
747
+                            pendingCheckpointDeletion = checkpoint
748
+                        }
749
+                    )
750
+                }
751
+                .padding(.top, 12)
752
+                .transition(.opacity.combined(with: .move(edge: .top)))
464 753
             }
465
-            if let batteryPrediction {
466
-                MeterInfoRowView(
467
-                    label: "Predicted Battery",
468
-                    value: "\(batteryPrediction.predictedPercent.format(decimalDigits: 0))%"
469
-                )
470
-                Text(
471
-                    "Anchored to \(batteryPrediction.anchorDescription) at \(batteryPrediction.anchorPercent.format(decimalDigits: 0))% using \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh estimated capacity."
472
-                )
473
-                .font(.caption2)
754
+        }
755
+        .frame(maxWidth: .infinity, alignment: .leading)
756
+        .padding(18)
757
+        .meterCard(tint: .orange, fillOpacity: 0.18, strokeOpacity: 0.24)
758
+    }
759
+
760
+    private func batteryPreviewBar(
761
+        startPercent: Double,
762
+        endPercent: Double,
763
+        checkpoints: [ChargeCheckpointSummary],
764
+        isEstimatedEnd: Bool
765
+    ) -> some View {
766
+        let startFrac = CGFloat(max(0, min(startPercent, 100)) / 100)
767
+        let endFrac   = CGFloat(max(0, min(endPercent,   100)) / 100)
768
+        let color = batteryColor(for: endPercent)
769
+
770
+        return HStack(spacing: 6) {
771
+            Text("\(Int(startPercent.rounded()))%")
772
+                .font(.caption2.weight(.semibold))
474 773
                 .foregroundColor(.secondary)
774
+                .monospacedDigit()
775
+                .frame(minWidth: 26, alignment: .trailing)
776
+
777
+            GeometryReader { geo in
778
+                let w = geo.size.width
779
+                ZStack(alignment: .leading) {
780
+                    Capsule()
781
+                        .fill(Color.primary.opacity(0.10))
782
+
783
+                    Rectangle()
784
+                        .fill(color.opacity(isEstimatedEnd ? 0.45 : 0.72))
785
+                        .frame(width: max(w * (endFrac - startFrac), 3))
786
+                        .offset(x: w * startFrac)
787
+
788
+                    ForEach(checkpoints, id: \.id) { cp in
789
+                        let xFrac = CGFloat(max(0, min(cp.batteryPercent, 100)) / 100)
790
+                        let isFinal = cp.flag == .final
791
+                        Rectangle()
792
+                            .fill(Color.white.opacity(isFinal ? 0.95 : 0.70))
793
+                            .frame(width: isFinal ? 2.0 : 1.5, height: 10)
794
+                            .offset(x: w * xFrac - (isFinal ? 1.0 : 0.75))
795
+                    }
796
+                }
797
+                .clipShape(Capsule())
475 798
             }
799
+            .frame(height: 8)
476 800
 
477
-            BatteryCheckpointSectionView(
478
-                sessionID: session.id,
479
-                checkpoints: session.checkpoints,
480
-                message: session.status.isOpen
481
-                    ? "The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction."
482
-                    : "These checkpoints were saved with this closed session and feed capacity estimation and charge-level prediction.",
483
-                canAddCheckpoint: hasMonitoringControls && appData.canAddBatteryCheckpoint(to: session.id),
484
-                canDeleteCheckpoint: hasMonitoringControls || session.status.isOpen == false,
485
-                requirementMessage: session.status.isOpen ? appData.batteryCheckpointCaptureRequirementMessage(for: session.id) : nil,
486
-                effectiveEnergyWhOverride: hasMonitoringControls ? displayedEnergyWh : nil,
487
-                measuredChargeAhOverride: hasMonitoringControls ? displayedChargeAh : nil,
488
-                onDelete: { checkpoint in
489
-                    pendingCheckpointDeletion = checkpoint
801
+            HStack(spacing: 1) {
802
+                if isEstimatedEnd {
803
+                    Text("~")
804
+                        .font(.caption2)
805
+                        .foregroundColor(.secondary)
490 806
                 }
491
-            )
807
+                Text("\(Int(endPercent.rounded()))%")
808
+                    .font(.caption2.weight(.semibold))
809
+                    .foregroundColor(isEstimatedEnd ? .secondary : color)
810
+                    .monospacedDigit()
811
+            }
812
+            .frame(minWidth: 32, alignment: .leading)
492 813
         }
493 814
     }
494 815
 
@@ -505,8 +826,8 @@ struct ChargeSessionDetailView: View {
505 826
             : nil
506 827
         let etaToFull = etaText(
507 828
             rateWhPerSec: rateWhPerSec,
508
-            remainingWh: max(prediction.estimatedCapacityWh - displayedEnergyWh, 0),
509
-            isRelevant: percent < 98
829
+            remainingWh: max((prediction.energyWh(forPercent: 100) ?? displayedEnergyWh) - displayedEnergyWh, 0),
830
+            isRelevant: percent < 98 && prediction.estimatedCapacityWh != nil
510 831
         )
511 832
         let etaToTarget = etaToTargetText(
512 833
             session: session,
@@ -529,14 +850,16 @@ struct ChargeSessionDetailView: View {
529 850
 
530 851
                 Spacer()
531 852
 
532
-                VStack(alignment: .trailing, spacing: 2) {
533
-                    Text("\(prediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh")
534
-                        .font(.callout.weight(.bold))
535
-                        .foregroundColor(.orange)
536
-                        .monospacedDigit()
537
-                    Text("est. capacity")
538
-                        .font(.caption2)
539
-                        .foregroundColor(.secondary)
853
+                if let estimatedCapacityWh = prediction.estimatedCapacityWh {
854
+                    VStack(alignment: .trailing, spacing: 2) {
855
+                        Text("\(estimatedCapacityWh.format(decimalDigits: 2)) Wh")
856
+                            .font(.callout.weight(.bold))
857
+                            .foregroundColor(.orange)
858
+                            .monospacedDigit()
859
+                        Text(prediction.basis.metricLabel)
860
+                            .font(.caption2)
861
+                            .foregroundColor(.secondary)
862
+                    }
540 863
                 }
541 864
             }
542 865
 
@@ -888,18 +1211,15 @@ struct ChargeSessionDetailView: View {
888 1211
 
889 1212
     private func stopConfirmPanel(
890 1213
         session: ChargeSessionSummary,
891
-        displayedEnergyWh: Double,
892
-        displayedChargeAh: Double
1214
+        displayedEnergyWh: Double
893 1215
     ) -> some View {
894 1216
         let canSave = hasSavableChargeData(
895 1217
             session: session,
896
-            displayedEnergyWh: displayedEnergyWh,
897
-            displayedChargeAh: displayedChargeAh
1218
+            displayedEnergyWh: displayedEnergyWh
898 1219
         )
899 1220
         let saveDisabledReason = saveDisabledReason(
900 1221
             session: session,
901
-            displayedEnergyWh: displayedEnergyWh,
902
-            displayedChargeAh: displayedChargeAh
1222
+            displayedEnergyWh: displayedEnergyWh
903 1223
         )
904 1224
         let isSaveEnabled = saveDisabledReason == nil
905 1225
 
@@ -944,8 +1264,7 @@ struct ChargeSessionDetailView: View {
944 1264
                 Button {
945 1265
                     stopSession(
946 1266
                         session,
947
-                        displayedEnergyWh: displayedEnergyWh,
948
-                        displayedChargeAh: displayedChargeAh
1267
+                        displayedEnergyWh: displayedEnergyWh
949 1268
                     )
950 1269
                 } label: {
951 1270
                     Label("Save Session", systemImage: "checkmark.circle.fill")
@@ -1124,10 +1443,17 @@ struct ChargeSessionDetailView: View {
1124 1443
         !session.aggregatedSamples.isEmpty || liveMonitoringMeter != nil
1125 1444
     }
1126 1445
 
1127
-    private func chartCard(_ session: ChargeSessionSummary) -> some View {
1446
+    private func chartCard(
1447
+        _ session: ChargeSessionSummary,
1448
+        chargedDevice: ChargedDeviceSummary
1449
+    ) -> some View {
1128 1450
         ChargeSessionChartCardView(
1129 1451
             session: session,
1130 1452
             monitoringMeter: liveMonitoringMeter,
1453
+            batteryPercentPoints: batteryPercentChartPoints(
1454
+                for: session,
1455
+                chargedDevice: chargedDevice
1456
+            ),
1131 1457
             controlMode: chartControlMode(for: session),
1132 1458
             onSetTrim: { start, end in
1133 1459
                 setSessionTrim(sessionID: session.id, start: start, end: end)
@@ -1141,7 +1467,39 @@ struct ChargeSessionDetailView: View {
1141 1467
                     confirmTitle: "Finish",
1142 1468
                     explanation: "The selected chart window will be saved as this session's active charging window before the session is closed."
1143 1469
                 )
1144
-            }
1470
+            },
1471
+            onCommitTrim: (session.status.isOpen == false && session.isTrimmed)
1472
+                ? {
1473
+                    pendingTrimCommitSession = session
1474
+                }
1475
+                : nil
1476
+        )
1477
+    }
1478
+
1479
+    private func powerbankChartCard(_ session: ChargeSessionSummary) -> some View {
1480
+        ChargeSessionChartCardView(
1481
+            session: session,
1482
+            monitoringMeter: liveMonitoringMeter,
1483
+            batteryPercentPoints: batteryPercentChartPoints(forPowerbankSession: session),
1484
+            controlMode: chartControlMode(for: session),
1485
+            onSetTrim: { start, end in
1486
+                setSessionTrim(sessionID: session.id, start: start, end: end)
1487
+            },
1488
+            onStopWithTrim: { start, end in
1489
+                requestStop(
1490
+                    session,
1491
+                    applyingTrimStart: start,
1492
+                    trimEnd: end,
1493
+                    title: "Trim End & Finish",
1494
+                    confirmTitle: "Finish",
1495
+                    explanation: "The selected chart window will be saved as this session's active charging window before the session is closed."
1496
+                )
1497
+            },
1498
+            onCommitTrim: (session.status.isOpen == false && session.isTrimmed)
1499
+                ? {
1500
+                    pendingTrimCommitSession = session
1501
+                }
1502
+                : nil
1145 1503
         )
1146 1504
     }
1147 1505
 
@@ -1162,6 +1520,160 @@ struct ChargeSessionDetailView: View {
1162 1520
         trimBannerDismissedForSessionID = sessionID
1163 1521
     }
1164 1522
 
1523
+    private func batteryPercentChartPoints(
1524
+        for session: ChargeSessionSummary,
1525
+        chargedDevice: ChargedDeviceSummary
1526
+    ) -> [Measurements.Measurement.Point] {
1527
+        var candidates: [BatteryPercentCandidate] = []
1528
+
1529
+        for sample in session.displayedAggregatedSamples {
1530
+            let percent = chargedDevice.batteryLevelPrediction(
1531
+                    for: session,
1532
+                    effectiveEnergyWhOverride: effectiveBatteryEnergyWh(
1533
+                        rawMeasuredEnergyWh: sample.measuredEnergyWh,
1534
+                        for: session
1535
+                    ),
1536
+                    referenceTimestamp: sample.timestamp
1537
+                )?.predictedPercent
1538
+                ?? sample.estimatedBatteryPercent
1539
+
1540
+            if let percent, percent.isFinite {
1541
+                candidates.append(
1542
+                    BatteryPercentCandidate(
1543
+                        timestamp: sample.timestamp,
1544
+                        percent: percent,
1545
+                        isCheckpoint: false
1546
+                    )
1547
+                )
1548
+            }
1549
+        }
1550
+
1551
+        for checkpoint in session.checkpoints where session.effectiveTimeRange.contains(checkpoint.timestamp) {
1552
+            guard checkpoint.batteryPercent.isFinite,
1553
+                  checkpoint.batteryPercent >= 0,
1554
+                  checkpoint.batteryPercent <= 100 else {
1555
+                continue
1556
+            }
1557
+            candidates.append(
1558
+                BatteryPercentCandidate(
1559
+                    timestamp: checkpoint.timestamp,
1560
+                    percent: checkpoint.batteryPercent,
1561
+                    isCheckpoint: true
1562
+                )
1563
+            )
1564
+        }
1565
+
1566
+        if hasMonitoringControls,
1567
+           let prediction = chargedDevice.batteryLevelPrediction(
1568
+            for: session,
1569
+            effectiveEnergyWhOverride: displayedSessionEnergyWh(for: session)
1570
+           ) {
1571
+            candidates.append(
1572
+                BatteryPercentCandidate(
1573
+                    timestamp: max(session.lastObservedAt, Date()),
1574
+                    percent: prediction.predictedPercent,
1575
+                    isCheckpoint: false
1576
+                )
1577
+            )
1578
+        }
1579
+
1580
+        let sortedCandidates = coalescedBatteryPercentCandidates(candidates).sorted { lhs, rhs in
1581
+            if lhs.timestamp != rhs.timestamp {
1582
+                return lhs.timestamp < rhs.timestamp
1583
+            }
1584
+            return lhs.isCheckpoint && !rhs.isCheckpoint
1585
+        }
1586
+
1587
+        var points: [Measurements.Measurement.Point] = []
1588
+        var previousCandidate: BatteryPercentCandidate?
1589
+
1590
+        for candidate in sortedCandidates {
1591
+            if let previousCandidate,
1592
+               candidate.timestamp.timeIntervalSince(previousCandidate.timestamp) > 90 {
1593
+                points.append(
1594
+                    Measurements.Measurement.Point(
1595
+                        id: points.count,
1596
+                        timestamp: candidate.timestamp,
1597
+                        value: points.last?.value ?? candidate.percent,
1598
+                        kind: .discontinuity
1599
+                    )
1600
+                )
1601
+            }
1602
+
1603
+            points.append(
1604
+                Measurements.Measurement.Point(
1605
+                    id: points.count,
1606
+                    timestamp: candidate.timestamp,
1607
+                    value: min(max(candidate.percent, 0), 100)
1608
+                )
1609
+            )
1610
+            previousCandidate = candidate
1611
+        }
1612
+
1613
+        return points
1614
+    }
1615
+
1616
+    private func batteryPercentChartPoints(
1617
+        forPowerbankSession session: ChargeSessionSummary
1618
+    ) -> [Measurements.Measurement.Point] {
1619
+        session.checkpoints
1620
+            .filter { session.effectiveTimeRange.contains($0.timestamp) }
1621
+            .sorted { $0.timestamp < $1.timestamp }
1622
+            .enumerated()
1623
+            .map { index, checkpoint in
1624
+                Measurements.Measurement.Point(
1625
+                    id: index,
1626
+                    timestamp: checkpoint.timestamp,
1627
+                    value: min(max(checkpoint.batteryPercent, 0), 100)
1628
+                )
1629
+            }
1630
+    }
1631
+
1632
+    private func coalescedBatteryPercentCandidates(
1633
+        _ candidates: [BatteryPercentCandidate]
1634
+    ) -> [BatteryPercentCandidate] {
1635
+        let sortedCandidates = candidates.sorted { lhs, rhs in
1636
+            if lhs.timestamp != rhs.timestamp {
1637
+                return lhs.timestamp < rhs.timestamp
1638
+            }
1639
+            return lhs.isCheckpoint && !rhs.isCheckpoint
1640
+        }
1641
+
1642
+        var coalesced: [BatteryPercentCandidate] = []
1643
+
1644
+        for candidate in sortedCandidates {
1645
+            if let last = coalesced.last,
1646
+               abs(candidate.timestamp.timeIntervalSince(last.timestamp)) <= 1 {
1647
+                if candidate.isCheckpoint || !last.isCheckpoint {
1648
+                    coalesced[coalesced.count - 1] = candidate
1649
+                }
1650
+            } else {
1651
+                coalesced.append(candidate)
1652
+            }
1653
+        }
1654
+
1655
+        return coalesced
1656
+    }
1657
+
1658
+    private func effectiveBatteryEnergyWh(
1659
+        rawMeasuredEnergyWh: Double,
1660
+        for session: ChargeSessionSummary
1661
+    ) -> Double {
1662
+        switch session.chargingTransportMode {
1663
+        case .wired:
1664
+            return rawMeasuredEnergyWh
1665
+        case .wireless:
1666
+            if let factor = session.wirelessEfficiencyFactor, factor > 0 {
1667
+                return rawMeasuredEnergyWh * factor
1668
+            }
1669
+            if let effectiveEnergyWh = session.effectiveBatteryEnergyWh,
1670
+               session.measuredEnergyWh > 0 {
1671
+                return rawMeasuredEnergyWh * (effectiveEnergyWh / session.measuredEnergyWh)
1672
+            }
1673
+            return rawMeasuredEnergyWh
1674
+        }
1675
+    }
1676
+
1165 1677
     private func requestStop(
1166 1678
         _ session: ChargeSessionSummary,
1167 1679
         applyingTrimStart trimStart: Date?,
@@ -1199,7 +1711,7 @@ struct ChargeSessionDetailView: View {
1199 1711
 
1200 1712
     private var resolvedFinalCheckpoint: Double? {
1201 1713
         switch finalCheckpointMode {
1202
-        case .full:   return 100
1714
+        case .full:   return suggestedFinalCheckpointPercent(for: session)
1203 1715
         case .skip:   return nil
1204 1716
         case .custom: return parsedFinalCheckpoint
1205 1717
         }
@@ -1232,18 +1744,15 @@ struct ChargeSessionDetailView: View {
1232 1744
 
1233 1745
     private func hasSavableChargeData(
1234 1746
         session: ChargeSessionSummary,
1235
-        displayedEnergyWh: Double,
1236
-        displayedChargeAh: Double
1747
+        displayedEnergyWh: Double
1237 1748
     ) -> Bool {
1238 1749
         session.hasSavableChargeData
1239 1750
             || displayedEnergyWh > 0
1240
-            || displayedChargeAh > 0
1241 1751
     }
1242 1752
 
1243 1753
     private func saveDisabledReason(
1244 1754
         session: ChargeSessionSummary,
1245
-        displayedEnergyWh: Double,
1246
-        displayedChargeAh: Double
1755
+        displayedEnergyWh: Double
1247 1756
     ) -> String? {
1248 1757
         if finalCheckpointMode == .custom {
1249 1758
             let trimmed = finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -1254,11 +1763,13 @@ struct ChargeSessionDetailView: View {
1254 1763
                 return "Final battery percentage must be between 0 and 100."
1255 1764
             }
1256 1765
         }
1766
+        if finalCheckpointMode == .full && resolvedFinalCheckpoint == nil {
1767
+            return "Full can be saved only when there is a current battery estimate. Choose Skip or enter the displayed percentage."
1768
+        }
1257 1769
 
1258 1770
         guard hasSavableChargeData(
1259 1771
             session: session,
1260
-            displayedEnergyWh: displayedEnergyWh,
1261
-            displayedChargeAh: displayedChargeAh
1772
+            displayedEnergyWh: displayedEnergyWh
1262 1773
         ) else {
1263 1774
             return "This session has no charging data to save. Discard it instead."
1264 1775
         }
@@ -1268,15 +1779,13 @@ struct ChargeSessionDetailView: View {
1268 1779
 
1269 1780
     private func stopSession(
1270 1781
         _ session: ChargeSessionSummary,
1271
-        displayedEnergyWh: Double,
1272
-        displayedChargeAh: Double
1782
+        displayedEnergyWh: Double
1273 1783
     ) {
1274 1784
         stopFailureMessage = nil
1275 1785
 
1276 1786
         if let saveDisabledReason = saveDisabledReason(
1277 1787
             session: session,
1278
-            displayedEnergyWh: displayedEnergyWh,
1279
-            displayedChargeAh: displayedChargeAh
1788
+            displayedEnergyWh: displayedEnergyWh
1280 1789
         ) {
1281 1790
             stopFailureMessage = saveDisabledReason
1282 1791
             return
@@ -1352,18 +1861,6 @@ struct ChargeSessionDetailView: View {
1352 1861
         return storedEnergyWh
1353 1862
     }
1354 1863
 
1355
-    private func displayedSessionChargeAh(for session: ChargeSessionSummary) -> Double {
1356
-        let storedChargeAh = session.measuredChargeAh
1357
-        guard session.isTrimmed == false else { return storedChargeAh }
1358
-        guard session.status.isOpen else { return storedChargeAh }
1359
-        guard let liveMonitoringMeter else { return storedChargeAh }
1360
-        guard session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else { return storedChargeAh }
1361
-        if let baselineChargeAh = session.meterChargeBaselineAh {
1362
-            return max(storedChargeAh, max(liveMonitoringMeter.recordedAH - baselineChargeAh, 0))
1363
-        }
1364
-        return storedChargeAh
1365
-    }
1366
-
1367 1864
     private func displayedSessionDuration(for session: ChargeSessionSummary) -> TimeInterval {
1368 1865
         let storedDuration = max(session.effectiveDuration, 0)
1369 1866
         guard session.isTrimmed == false else { return storedDuration }
@@ -1464,7 +1961,9 @@ struct ChargeSessionDetailView: View {
1464 1961
         guard let target = session.targetBatteryPercent, target > prediction.predictedPercent + 1 else {
1465 1962
             return nil
1466 1963
         }
1467
-        let targetEnergyWh = (target / 100) * prediction.estimatedCapacityWh
1964
+        guard let targetEnergyWh = prediction.energyWh(forPercent: target) else {
1965
+            return nil
1966
+        }
1468 1967
         return etaText(
1469 1968
             rateWhPerSec: rateWhPerSec,
1470 1969
             remainingWh: max(targetEnergyWh - displayedEnergyWh, 0),
@@ -1472,6 +1971,14 @@ struct ChargeSessionDetailView: View {
1472 1971
         )
1473 1972
     }
1474 1973
 
1974
+    private func batteryPredictionExplanation(_ prediction: BatteryLevelPrediction) -> String {
1975
+        let anchor = "Anchored to \(prediction.anchorDescription) at \(prediction.anchorPercent.format(decimalDigits: 0))%"
1976
+        guard let estimatedCapacityWh = prediction.estimatedCapacityWh else {
1977
+            return "\(anchor)."
1978
+        }
1979
+        return "\(anchor) using \(estimatedCapacityWh.format(decimalDigits: 2)) Wh \(prediction.basis.explanatoryLabel)."
1980
+    }
1981
+
1475 1982
     private func formatETA(_ seconds: TimeInterval) -> String {
1476 1983
         let totalMinutes = Int(seconds / 60)
1477 1984
         if totalMinutes < 60 { return "\(totalMinutes)m" }
@@ -1539,9 +2046,11 @@ enum ChargeSessionChartControlMode {
1539 2046
 struct ChargeSessionChartCardView: View {
1540 2047
     let session: ChargeSessionSummary
1541 2048
     let monitoringMeter: Meter?
2049
+    let batteryPercentPoints: [Measurements.Measurement.Point]
1542 2050
     let controlMode: ChargeSessionChartControlMode
1543 2051
     let onSetTrim: (Date?, Date?) -> Void
1544 2052
     let onStopWithTrim: (Date?, Date?) -> Void
2053
+    let onCommitTrim: (() -> Void)?
1545 2054
 
1546 2055
     @StateObject private var storedMeasurements = Measurements()
1547 2056
 
@@ -1600,10 +2109,35 @@ struct ChargeSessionChartCardView: View {
1600 2109
                 rebasesEnergyToVisibleRangeStart: true,
1601 2110
                 extendsTimelineToPresent: false,
1602 2111
                 showsTemperatureSeries: false,
2112
+                showsBatteryPercentSeries: shouldShowBatteryPercentSeries,
2113
+                batteryCheckpoints: session.checkpoints,
2114
+                batteryPercentPoints: batteryPercentPoints,
1603 2115
                 rangeSelectorConfiguration: rangeSelectorConfiguration
1604 2116
             )
1605 2117
             .environmentObject(chartMeasurements)
1606 2118
             .frame(maxWidth: .infinity, alignment: .topLeading)
2119
+
2120
+            if let onCommitTrim {
2121
+                Divider()
2122
+
2123
+                HStack(alignment: .center, spacing: 10) {
2124
+                    Label("Save trim permanently", systemImage: "internaldrive")
2125
+                        .font(.caption.weight(.semibold))
2126
+                        .foregroundColor(.secondary)
2127
+
2128
+                    Spacer(minLength: 0)
2129
+
2130
+                    Button {
2131
+                        onCommitTrim()
2132
+                    } label: {
2133
+                        Label("Save Trim", systemImage: "checkmark.seal")
2134
+                            .font(.caption.weight(.semibold))
2135
+                    }
2136
+                    .buttonStyle(.borderedProminent)
2137
+                    .controlSize(.small)
2138
+                    .tint(.red)
2139
+                }
2140
+            }
1607 2141
         }
1608 2142
         .padding(18)
1609 2143
         .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20)
@@ -1614,6 +2148,9 @@ struct ChargeSessionChartCardView: View {
1614 2148
         .onChange(of: session.aggregatedSamples.count) { _ in
1615 2149
             restoreStoredMeasurementsIfNeeded()
1616 2150
         }
2151
+        .onChange(of: session.checkpoints.count) { _ in
2152
+            restoreStoredMeasurementsIfNeeded()
2153
+        }
1617 2154
     }
1618 2155
 
1619 2156
     private var chartInfoMessage: String {
@@ -1624,6 +2161,10 @@ struct ChargeSessionChartCardView: View {
1624 2161
         return "This chart is scoped to the saved session window for \(session.sessionKind.shortTitle.lowercased()) charging."
1625 2162
     }
1626 2163
 
2164
+    private var shouldShowBatteryPercentSeries: Bool {
2165
+        !batteryPercentPoints.isEmpty
2166
+    }
2167
+
1627 2168
     private var rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration? {
1628 2169
         switch controlMode {
1629 2170
         case .none:
@@ -1654,7 +2195,8 @@ struct ChargeSessionChartCardView: View {
1654 2195
                     handler: {
1655 2196
                         onSetTrim(nil, nil)
1656 2197
                     }
1657
-                )
2198
+                ),
2199
+                exportAction: sessionCSVExportAction
1658 2200
             )
1659 2201
         case .closed:
1660 2202
             return MeasurementChartRangeSelectorConfiguration(
@@ -1676,11 +2218,98 @@ struct ChargeSessionChartCardView: View {
1676 2218
                     handler: {
1677 2219
                         onSetTrim(nil, nil)
1678 2220
                     }
1679
-                )
2221
+                ),
2222
+                exportAction: sessionCSVExportAction
1680 2223
             )
1681 2224
         }
1682 2225
     }
1683 2226
 
2227
+    private var sessionCSVExportAction: MeasurementChartExportAction {
2228
+        MeasurementChartExportAction(
2229
+            title: "Export CSV",
2230
+            shortTitle: "CSV",
2231
+            systemName: "square.and.arrow.up",
2232
+            tone: .reversible,
2233
+            fileName: sessionCSVFileName,
2234
+            content: sessionCSVContent
2235
+        )
2236
+    }
2237
+
2238
+    private func sessionCSVFileName(for range: ClosedRange<Date>) -> String {
2239
+        let formatter = DateFormatter()
2240
+        formatter.locale = Locale(identifier: "en_US_POSIX")
2241
+        formatter.timeZone = .current
2242
+        formatter.dateFormat = "yyyyMMdd-HHmmss"
2243
+
2244
+        return [
2245
+            "charge-session",
2246
+            formatter.string(from: range.lowerBound),
2247
+            formatter.string(from: range.upperBound)
2248
+        ].joined(separator: "-")
2249
+    }
2250
+
2251
+    private func sessionCSVContent(for range: ClosedRange<Date>) -> String {
2252
+        let samples = session.aggregatedSamples
2253
+            .filter { range.contains($0.timestamp) }
2254
+            .sorted { lhs, rhs in
2255
+                if lhs.bucketIndex != rhs.bucketIndex {
2256
+                    return lhs.bucketIndex < rhs.bucketIndex
2257
+                }
2258
+                return lhs.timestamp < rhs.timestamp
2259
+            }
2260
+
2261
+        let timestampFormatter = ISO8601DateFormatter()
2262
+        timestampFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
2263
+
2264
+        var rows: [[String]] = [
2265
+            [
2266
+                "Timestamp",
2267
+                "Elapsed Seconds",
2268
+                "Voltage (V)",
2269
+                "Current (A)",
2270
+                "Power (W)",
2271
+                "Session Energy (Wh)",
2272
+                "Interval Energy (Wh)",
2273
+                "Battery (%)",
2274
+                "Sample Count"
2275
+            ]
2276
+        ]
2277
+
2278
+        let intervalEnergyBaseline = samples.first?.measuredEnergyWh ?? 0
2279
+        for sample in samples {
2280
+            rows.append([
2281
+                timestampFormatter.string(from: sample.timestamp),
2282
+                formattedCSVNumber(sample.timestamp.timeIntervalSince(session.startedAt), fractionDigits: 3),
2283
+                formattedCSVNumber(sample.averageVoltageVolts, fractionDigits: 6),
2284
+                formattedCSVNumber(sample.averageCurrentAmps, fractionDigits: 6),
2285
+                formattedCSVNumber(sample.averagePowerWatts, fractionDigits: 6),
2286
+                formattedCSVNumber(sample.measuredEnergyWh, fractionDigits: 6),
2287
+                formattedCSVNumber(max(sample.measuredEnergyWh - intervalEnergyBaseline, 0), fractionDigits: 6),
2288
+                formattedCSVNumber(sample.estimatedBatteryPercent, fractionDigits: 3),
2289
+                "\(sample.sampleCount)"
2290
+            ])
2291
+        }
2292
+
2293
+        return rows
2294
+            .map { row in row.map(escapedCSVField).joined(separator: ",") }
2295
+            .joined(separator: "\n")
2296
+    }
2297
+
2298
+    private func formattedCSVNumber(_ value: Double?, fractionDigits: Int) -> String {
2299
+        guard let value, value.isFinite else { return "" }
2300
+        return String(
2301
+            format: "%.\(fractionDigits)f",
2302
+            locale: Locale(identifier: "en_US_POSIX"),
2303
+            value
2304
+        )
2305
+    }
2306
+
2307
+    private func escapedCSVField(_ field: String) -> String {
2308
+        let mustQuote = field.contains(",") || field.contains("\"") || field.contains("\n")
2309
+        guard mustQuote else { return field }
2310
+        return "\"\(field.replacingOccurrences(of: "\"", with: "\"\""))\""
2311
+    }
2312
+
1684 2313
     private func restoreStoredMeasurementsIfNeeded() {
1685 2314
         guard monitoringMeter == nil || session.status.isOpen == false else {
1686 2315
             return
+3 -1
USB Meter/Views/ChargedDevices/Sessions/ChargedDeviceSessionsView.swift
@@ -64,10 +64,12 @@ struct ChargedDeviceSessionsView: View {
64 64
                     .ignoresSafeArea()
65 65
                 )
66 66
                 .navigationTitle("Sessions")
67
+                .navigationBarTitleDisplayMode(.inline)
67 68
             } else {
68 69
                 Text("This device is no longer available.")
69 70
                     .foregroundColor(.secondary)
70 71
                     .navigationTitle("Sessions")
72
+                    .navigationBarTitleDisplayMode(.inline)
71 73
             }
72 74
         }
73 75
         .alert(item: $pendingSessionDeletion) { session in
@@ -244,7 +246,7 @@ struct ChargedDeviceSessionsView: View {
244 246
         let start = session.startBatteryPercent
245 247
         let end = session.endBatteryPercent
246 248
 
247
-        if let s = start, let e = end, e > s {
249
+        if let s = start, let e = end, s >= 0, e > s {
248 250
             return (s, e)
249 251
         }
250 252
 
+109 -13
USB Meter/Views/ChargedDevices/Sheets/ChargeSession/BatteryCheckpointEditorSheetView.swift
@@ -13,19 +13,19 @@ struct BatteryCheckpointEditorContentView: View {
13 13
     let sessionID: UUID
14 14
     let message: String
15 15
     let effectiveEnergyWhOverride: Double?
16
-    let measuredChargeAhOverride: Double?
17 16
     let onCancel: (() -> Void)?
18 17
     let onSaved: (() -> Void)?
19 18
     let showsHeader: Bool
20 19
 
21 20
     @State private var batteryPercent = ""
21
+    @State private var barsValue: Int = 0
22
+    @State private var subject: CheckpointSubject = .chargedDevice
22 23
     @State private var showsWarningPopover = false
23 24
 
24 25
     init(
25 26
         sessionID: UUID,
26 27
         message: String,
27 28
         effectiveEnergyWhOverride: Double?,
28
-        measuredChargeAhOverride: Double?,
29 29
         onCancel: (() -> Void)?,
30 30
         onSaved: (() -> Void)?,
31 31
         showsHeader: Bool = true
@@ -33,12 +33,45 @@ struct BatteryCheckpointEditorContentView: View {
33 33
         self.sessionID = sessionID
34 34
         self.message = message
35 35
         self.effectiveEnergyWhOverride = effectiveEnergyWhOverride
36
-        self.measuredChargeAhOverride = measuredChargeAhOverride
37 36
         self.onCancel = onCancel
38 37
         self.onSaved = onSaved
39 38
         self.showsHeader = showsHeader
40 39
     }
41 40
 
41
+    private var sourcePowerbank: PowerbankSummary? {
42
+        guard let session = appData.chargeSessionSummary(id: sessionID),
43
+              let powerbankID = session.sourcePowerbankID else {
44
+            return nil
45
+        }
46
+        return appData.powerbankSummaries.first { $0.id == powerbankID }
47
+    }
48
+
49
+    private var chargedPowerbank: PowerbankSummary? {
50
+        guard let session = appData.chargeSessionSummary(id: sessionID),
51
+              let powerbankID = session.chargedPowerbankID else {
52
+            return nil
53
+        }
54
+        return appData.powerbankSummaries.first { $0.id == powerbankID }
55
+    }
56
+
57
+    private var allowsSubjectToggle: Bool {
58
+        chargedPowerbank == nil && sourcePowerbank?.batteryLevelReporting.allowsCheckpoints == true
59
+    }
60
+
61
+    private var activeReporting: BatteryLevelReporting {
62
+        if let chargedPowerbank {
63
+            return chargedPowerbank.batteryLevelReporting
64
+        }
65
+        if subject == .powerbank, let sourcePowerbank {
66
+            return sourcePowerbank.batteryLevelReporting
67
+        }
68
+        return .percent
69
+    }
70
+
71
+    private var activeBarsCount: Int {
72
+        max(1, (chargedPowerbank ?? sourcePowerbank)?.batteryBarsCount ?? 1)
73
+    }
74
+
42 75
     private var plausibilityWarning: BatteryCheckpointPlausibilityWarning? {
43 76
         guard let percent = normalizedBatteryPercent else {
44 77
             return nil
@@ -58,10 +91,18 @@ struct BatteryCheckpointEditorContentView: View {
58 91
     }
59 92
 
60 93
     private var canSave: Bool {
61
-        guard let percent = normalizedBatteryPercent else {
94
+        switch activeReporting {
95
+        case .percent:
96
+            guard let percent = normalizedBatteryPercent else { return false }
97
+            return percent >= 0 && percent <= 100
98
+        case .bars:
99
+            return barsValue >= 0 && barsValue <= activeBarsCount
100
+        case .fullOnly:
101
+            // Always savable — the only emitted value is the 100% anchor.
102
+            return true
103
+        case .none:
62 104
             return false
63 105
         }
64
-        return percent >= 0 && percent <= 100
65 106
     }
66 107
 
67 108
     var body: some View {
@@ -77,17 +118,58 @@ struct BatteryCheckpointEditorContentView: View {
77 118
                 }
78 119
             }
79 120
 
121
+            if allowsSubjectToggle {
122
+                Picker("Subject", selection: $subject) {
123
+                    Text("Device").tag(CheckpointSubject.chargedDevice)
124
+                    Text("Powerbank").tag(CheckpointSubject.powerbank)
125
+                }
126
+                .pickerStyle(.segmented)
127
+            }
128
+
80 129
             compactEditorRow
81 130
         }
131
+        .onAppear {
132
+            if chargedPowerbank != nil {
133
+                subject = .powerbank
134
+            }
135
+        }
82 136
     }
83 137
 
84
-    private var compactEditorRow: some View {
85
-        HStack(spacing: 8) {
138
+    @ViewBuilder
139
+    private var subjectInput: some View {
140
+        switch activeReporting {
141
+        case .percent:
86 142
             TextField("Battery %", text: $batteryPercent)
87 143
                 .keyboardType(.decimalPad)
88 144
                 .textFieldStyle(.roundedBorder)
89 145
                 .frame(width: 104)
90 146
                 .onSubmit(saveCheckpoint)
147
+        case .bars:
148
+            HStack(spacing: 6) {
149
+                Stepper(value: $barsValue, in: 0...activeBarsCount) {
150
+                    Text("\(barsValue) / \(activeBarsCount)")
151
+                        .font(.subheadline)
152
+                }
153
+                .frame(width: 160)
154
+            }
155
+        case .fullOnly:
156
+            // Single-LED powerbanks only signal completion. The only meaningful checkpoint
157
+            // is "full" — anything else would be a guess. Tapping the action saves at 100%.
158
+            Label("Full LED is on", systemImage: "lightbulb.fill")
159
+                .font(.caption)
160
+                .foregroundColor(.secondary)
161
+                .frame(width: 220, alignment: .leading)
162
+        case .none:
163
+            Text("Battery level reporting disabled")
164
+                .font(.caption)
165
+                .foregroundColor(.secondary)
166
+                .frame(width: 220, alignment: .leading)
167
+        }
168
+    }
169
+
170
+    private var compactEditorRow: some View {
171
+        HStack(spacing: 8) {
172
+            subjectInput
91 173
 
92 174
             if let plausibilityWarning {
93 175
                 Button {
@@ -160,15 +242,32 @@ struct BatteryCheckpointEditorContentView: View {
160 242
     }
161 243
 
162 244
     private func saveCheckpoint() {
163
-        guard let percent = normalizedBatteryPercent else {
245
+        let resolvedPercent: Double
246
+        let resolvedBars: Int
247
+        switch activeReporting {
248
+        case .percent:
249
+            guard let percent = normalizedBatteryPercent else { return }
250
+            resolvedPercent = percent
251
+            resolvedBars = 0
252
+        case .bars:
253
+            resolvedBars = barsValue
254
+            resolvedPercent = activeBarsCount > 0
255
+                ? min(100, max(0, Double(barsValue) / Double(activeBarsCount) * 100))
256
+                : 0
257
+        case .fullOnly:
258
+            // Single-LED powerbanks: the only meaningful anchor is "full".
259
+            resolvedPercent = 100
260
+            resolvedBars = 0
261
+        case .none:
164 262
             return
165 263
         }
166 264
 
167 265
         if appData.addBatteryCheckpoint(
168
-            percent: percent,
266
+            percent: resolvedPercent,
169 267
             for: sessionID,
170 268
             measuredEnergyWh: effectiveEnergyWhOverride,
171
-            measuredChargeAh: measuredChargeAhOverride
269
+            subject: subject,
270
+            barsValue: resolvedBars
172 271
         ) {
173 272
             onSaved?()
174 273
         }
@@ -183,7 +282,6 @@ struct BatteryCheckpointSectionView: View {
183 282
     let canDeleteCheckpoint: Bool
184 283
     let requirementMessage: String?
185 284
     let effectiveEnergyWhOverride: Double?
186
-    let measuredChargeAhOverride: Double?
187 285
     let onDelete: (ChargeCheckpointSummary) -> Void
188 286
 
189 287
     @State private var showsInlineCheckpointEditor = false
@@ -211,7 +309,6 @@ struct BatteryCheckpointSectionView: View {
211 309
                             sessionID: sessionID,
212 310
                             message: message,
213 311
                             effectiveEnergyWhOverride: effectiveEnergyWhOverride,
214
-                            measuredChargeAhOverride: measuredChargeAhOverride,
215 312
                             onCancel: { showsInlineCheckpointEditor = false },
216 313
                             onSaved: { showsInlineCheckpointEditor = false },
217 314
                             showsHeader: false
@@ -294,7 +391,6 @@ struct BatteryCheckpointEditorSheetView: View {
294 391
                             sessionID: activeSession.id,
295 392
                             message: "The checkpoint is stored on the active charge session and later used for capacity estimation and the typical charge curve.",
296 393
                             effectiveEnergyWhOverride: nil,
297
-                            measuredChargeAhOverride: nil,
298 394
                             onCancel: { dismiss() },
299 395
                             onSaved: { dismiss() },
300 396
                             showsHeader: true
+4 -13
USB Meter/Views/ChargedDevices/Sheets/ChargeSession/ChargeSessionCompletionSheetView.swift
@@ -180,7 +180,6 @@ struct ChargeSessionCompletionSheetView: View {
180 180
         guard let session else { return false }
181 181
         return session.hasSavableChargeData
182 182
             || displayedSessionEnergyWh(for: session) > 0
183
-            || displayedSessionChargeAh(for: session) > 0
184 183
     }
185 184
 
186 185
     private var saveDisabledReason: String? {
@@ -193,6 +192,9 @@ struct ChargeSessionCompletionSheetView: View {
193 192
                 return "Final battery percentage must be between 0 and 100."
194 193
             }
195 194
         }
195
+        if finalCheckpoint == .full && resolvedFinalBatteryPercent == nil {
196
+            return "Full can be saved only when there is a current battery estimate. Choose Skip or enter the displayed percentage."
197
+        }
196 198
 
197 199
         guard hasChargeDataToSave else {
198 200
             return "This session has no charging data to save. Discard it instead."
@@ -211,7 +213,7 @@ struct ChargeSessionCompletionSheetView: View {
211 213
 
212 214
     private var resolvedFinalBatteryPercent: Double? {
213 215
         switch finalCheckpoint {
214
-        case .full:   return 100
216
+        case .full:   return suggestedFinalBatteryPercent
215 217
         case .skip:   return nil
216 218
         case .custom: return parsedBatteryPercent
217 219
         }
@@ -252,15 +254,4 @@ struct ChargeSessionCompletionSheetView: View {
252 254
         return storedEnergyWh
253 255
     }
254 256
 
255
-    private func displayedSessionChargeAh(for session: ChargeSessionSummary) -> Double {
256
-        let storedChargeAh = session.measuredChargeAh
257
-        guard session.isTrimmed == false else { return storedChargeAh }
258
-        guard session.status.isOpen else { return storedChargeAh }
259
-        guard let monitoringMeter else { return storedChargeAh }
260
-        guard session.meterMACAddress == monitoringMeter.btSerial.macAddress.description else { return storedChargeAh }
261
-        if let baselineChargeAh = session.meterChargeBaselineAh {
262
-            return max(storedChargeAh, max(monitoringMeter.recordedAH - baselineChargeAh, 0))
263
-        }
264
-        return storedChargeAh
265
-    }
266 257
 }
+271 -148
USB Meter/Views/ChargedDevices/Sheets/Editors/ChargedDeviceEditorSheetView.swift
@@ -11,28 +11,26 @@ struct ChargedDeviceEditorSheetView: View {
11 11
     @EnvironmentObject private var appData: AppData
12 12
     @Environment(\.dismiss) private var dismiss
13 13
 
14
-    let meterMACAddress: String?
15 14
     let chargedDevice: ChargedDeviceSummary?
16 15
 
17 16
     @State private var name: String
18 17
     @State private var notes: String
19 18
     @State private var deviceClass: ChargedDeviceClass
20
-    @State private var selectedTemplateID: String?
21
-    @State private var lastAppliedTemplateID: String?
19
+    @State private var selectedProfileID: String?
20
+    @State private var lastAppliedProfileID: String?
22 21
     @State private var chargingStateAvailability: ChargingStateAvailability
23 22
     @State private var supportsWiredCharging: Bool
24 23
     @State private var supportsWirelessCharging: Bool
25 24
     @State private var wirelessChargingProfile: WirelessChargingProfile
25
+    @State private var hasInternalSubject: Bool
26 26
     @State private var completionCurrentTexts: [ChargeSessionKind: String]
27 27
 
28 28
     let standalone: Bool
29 29
 
30 30
     init(
31
-        meterMACAddress: String?,
32 31
         chargedDevice: ChargedDeviceSummary? = nil,
33 32
         standalone: Bool = true
34 33
     ) {
35
-        self.meterMACAddress = meterMACAddress
36 34
         self.chargedDevice = chargedDevice
37 35
         self.standalone = standalone
38 36
 
@@ -40,22 +38,60 @@ struct ChargedDeviceEditorSheetView: View {
40 38
         _notes = State(initialValue: chargedDevice?.notes ?? "")
41 39
 
42 40
         let initialDeviceClass = chargedDevice?.deviceClass ?? .iphone
43
-        let initialChargingStateAvailability = initialDeviceClass.normalizedChargingStateAvailability(
44
-            chargedDevice?.chargingStateAvailability ?? initialDeviceClass.defaultChargingStateAvailability
45
-        )
46
-        let defaultChargingSupport = initialDeviceClass.defaultChargingSupport
47
-        let initialChargingSupport = initialDeviceClass.normalizedChargingSupport(
48
-            supportsWiredCharging: chargedDevice?.supportsWiredCharging ?? defaultChargingSupport.wired,
49
-            supportsWirelessCharging: chargedDevice?.supportsWirelessCharging ?? defaultChargingSupport.wireless
50
-        )
51
-        let initialTemplateID = chargedDevice?.deviceTemplateID
41
+        let initialProfileID = Self.resolveInitialProfileID(for: chargedDevice)
42
+        let initialProfile = DeviceProfileCatalog.shared.profile(id: initialProfileID)
43
+
44
+        let initialChargingStateAvailability: ChargingStateAvailability
45
+        let initialSupportsWired: Bool
46
+        let initialSupportsWireless: Bool
47
+        let initialWirelessProfile: WirelessChargingProfile
48
+        let initialHasInternalSubject: Bool
49
+
50
+        if let initialProfile {
51
+            let coerced = DeviceProfileValidator.coerce(
52
+                state: DeviceProfileValidator.AppliedState(
53
+                    chargingStateAvailability: chargedDevice?.chargingStateAvailability
54
+                        ?? initialProfile.capChargingStateAvailability,
55
+                    supportsWiredCharging: chargedDevice?.supportsWiredCharging
56
+                        ?? initialProfile.capWiredCharging,
57
+                    supportsWirelessCharging: chargedDevice?.supportsWirelessCharging
58
+                        ?? initialProfile.capWirelessCharging,
59
+                    wirelessChargingProfile: chargedDevice?.wirelessChargingProfile
60
+                        ?? initialProfile.defaultWirelessChargingProfile
61
+                        ?? .genericQi,
62
+                    hasInternalSubject: chargedDevice?.hasInternalSubject ?? false
63
+                ),
64
+                to: initialProfile
65
+            )
66
+            initialChargingStateAvailability = coerced.chargingStateAvailability
67
+            initialSupportsWired = coerced.supportsWiredCharging
68
+            initialSupportsWireless = coerced.supportsWirelessCharging
69
+            initialWirelessProfile = coerced.wirelessChargingProfile
70
+            initialHasInternalSubject = coerced.hasInternalSubject
71
+        } else {
72
+            // Custom mode — use legacy class enforcement as a fallback.
73
+            initialChargingStateAvailability = initialDeviceClass.normalizedChargingStateAvailability(
74
+                chargedDevice?.chargingStateAvailability ?? initialDeviceClass.defaultChargingStateAvailability
75
+            )
76
+            let defaultSupport = initialDeviceClass.defaultChargingSupport
77
+            let normalizedSupport = initialDeviceClass.normalizedChargingSupport(
78
+                supportsWiredCharging: chargedDevice?.supportsWiredCharging ?? defaultSupport.wired,
79
+                supportsWirelessCharging: chargedDevice?.supportsWirelessCharging ?? defaultSupport.wireless
80
+            )
81
+            initialSupportsWired = normalizedSupport.wired
82
+            initialSupportsWireless = normalizedSupport.wireless
83
+            initialWirelessProfile = chargedDevice?.wirelessChargingProfile ?? .genericQi
84
+            initialHasInternalSubject = chargedDevice?.hasInternalSubject ?? false
85
+        }
86
+
52 87
         _deviceClass = State(initialValue: initialDeviceClass)
53
-        _selectedTemplateID = State(initialValue: initialTemplateID)
54
-        _lastAppliedTemplateID = State(initialValue: initialTemplateID)
88
+        _selectedProfileID = State(initialValue: initialProfileID)
89
+        _lastAppliedProfileID = State(initialValue: initialProfileID)
55 90
         _chargingStateAvailability = State(initialValue: initialChargingStateAvailability)
56
-        _supportsWiredCharging = State(initialValue: initialChargingSupport.wired)
57
-        _supportsWirelessCharging = State(initialValue: initialChargingSupport.wireless)
58
-        _wirelessChargingProfile = State(initialValue: chargedDevice?.wirelessChargingProfile ?? .genericQi)
91
+        _supportsWiredCharging = State(initialValue: initialSupportsWired)
92
+        _supportsWirelessCharging = State(initialValue: initialSupportsWireless)
93
+        _wirelessChargingProfile = State(initialValue: initialWirelessProfile)
94
+        _hasInternalSubject = State(initialValue: initialHasInternalSubject)
59 95
         _completionCurrentTexts = State(initialValue: Self.makeCompletionCurrentTexts(for: chargedDevice))
60 96
     }
61 97
 
@@ -68,38 +104,41 @@ struct ChargedDeviceEditorSheetView: View {
68 104
             save: save
69 105
         ) {
70 106
             identitySection
71
-            templateSection
107
+            profileSection
108
+            if selectedProfile == nil {
109
+                customClassSection
110
+            }
72 111
             deviceChargeBehaviourSection
73 112
             deviceChargingSupportSection
113
+            if let profile = selectedProfile, profile.capHasInternalSubject {
114
+                internalSubjectSection(for: profile)
115
+            }
74 116
             deviceCompletionSection
75 117
             notesSection
76 118
         }
77 119
         .onChange(of: deviceClass) { newValue in
78
-            applyDeviceClassRules(for: newValue)
120
+            applyDeviceClassRulesIfCustom(for: newValue)
79 121
         }
80
-        .onChange(of: selectedTemplateID) { newValue in
81
-            applyTemplateSelection(
82
-                previousTemplateID: lastAppliedTemplateID,
83
-                newTemplateID: newValue
122
+        .onChange(of: selectedProfileID) { newValue in
123
+            applyProfileSelection(
124
+                previousProfileID: lastAppliedProfileID,
125
+                newProfileID: newValue
84 126
             )
85
-            lastAppliedTemplateID = newValue
127
+            lastAppliedProfileID = newValue
86 128
         }
87 129
         .onAppear {
88
-            applyDeviceClassRules(for: deviceClass)
130
+            if selectedProfile == nil {
131
+                applyDeviceClassRulesIfCustom(for: deviceClass)
132
+            }
89 133
         }
90 134
     }
91 135
 
136
+    // MARK: - Sections
137
+
92 138
     private var identitySection: some View {
93 139
         Section(header: Text("Identity")) {
94 140
             TextField("Name", text: $name)
95 141
 
96
-            Picker("Class", selection: $deviceClass) {
97
-                ForEach(ChargedDeviceClass.deviceCases) { deviceClass in
98
-                    Label(deviceClass.title, systemImage: deviceClass.symbolName)
99
-                        .tag(deviceClass)
100
-                }
101
-            }
102
-
103 142
             if let chargedDevice {
104 143
                 Text(chargedDevice.qrIdentifier)
105 144
                     .font(.caption.monospaced())
@@ -109,45 +148,55 @@ struct ChargedDeviceEditorSheetView: View {
109 148
         }
110 149
     }
111 150
 
112
-    private var templateSection: some View {
151
+    private var profileSection: some View {
113 152
         Section(
114 153
             header: ContextInfoHeader(
115
-                title: "Template",
116
-                message: "Templates load from a JSON catalog and provide the starting icon and charging profile for common devices and chargers."
154
+                title: "Profile",
155
+                message: "Profiles describe what a device is and what it can do. Pick a catalog profile to apply its capabilities, or use Custom for a free-form configuration."
117 156
             )
118 157
         ) {
119
-            Picker("Template", selection: $selectedTemplateID) {
120
-                Text("Custom")
121
-                    .tag(String?.none)
158
+            Picker("Profile", selection: $selectedProfileID) {
159
+                Text("Custom").tag(String?.none)
122 160
 
123
-                ForEach(groupedTemplates, id: \.group) { group in
161
+                ForEach(groupedProfiles, id: \.group) { group in
124 162
                     Section(group.group) {
125
-                        ForEach(group.templates) { template in
126
-                            Text(template.name)
127
-                                .tag(template.id as String?)
163
+                        ForEach(group.profiles) { profile in
164
+                            Text(profile.name).tag(profile.id as String?)
128 165
                         }
129 166
                     }
130 167
                 }
131 168
             }
132 169
 
133
-            if let selectedTemplate {
134
-                ChargedDeviceTemplateLabelView(
135
-                    template: selectedTemplate,
136
-                    iconPointSize: 18
137
-                )
138
-                .font(.subheadline.weight(.semibold))
170
+            if let profile = selectedProfile {
171
+                VStack(alignment: .leading, spacing: 4) {
172
+                    Label(profile.name, systemImage: profile.icon.resolvedSystemSymbolName(
173
+                        fallbackSystemName: profile.category.symbolName
174
+                    ))
175
+                    .font(.subheadline.weight(.semibold))
139 176
 
140
-                Text(selectedTemplate.capabilitySummary)
141
-                    .font(.caption)
142
-                    .foregroundColor(.secondary)
177
+                    Text(profile.capabilitySummary)
178
+                        .font(.caption)
179
+                        .foregroundColor(.secondary)
180
+                }
143 181
             } else {
144
-                Text("Choose a template when you want a predefined icon and a starting charging setup.")
182
+                Text("Custom devices use the class picker below for taxonomy and let you configure every capability manually.")
145 183
                     .font(.caption)
146 184
                     .foregroundColor(.secondary)
147 185
             }
148 186
         }
149 187
     }
150 188
 
189
+    private var customClassSection: some View {
190
+        Section(header: Text("Class")) {
191
+            Picker("Class", selection: $deviceClass) {
192
+                ForEach(ChargedDeviceClass.deviceCases) { deviceClass in
193
+                    Label(deviceClass.title, systemImage: deviceClass.symbolName)
194
+                        .tag(deviceClass)
195
+                }
196
+            }
197
+        }
198
+    }
199
+
151 200
     private var deviceChargeBehaviourSection: some View {
152 201
         Section(
153 202
             header: ContextInfoHeader(
@@ -155,7 +204,23 @@ struct ChargedDeviceEditorSheetView: View {
155 204
                 message: "Choose whether sessions for this device are recorded only while it is on, only while it is off, or explicitly in either state."
156 205
             )
157 206
         ) {
158
-            if let enforcedChargingStateAvailability = deviceClass.enforcedChargingStateAvailability {
207
+            if let profile = selectedProfile {
208
+                if DeviceProfileValidator.chargingStateIsLocked(profile) {
209
+                    VStack(alignment: .leading, spacing: 6) {
210
+                        Label(profile.capChargingStateAvailability.title, systemImage: "lock.fill")
211
+                            .font(.subheadline.weight(.semibold))
212
+                        Text(profile.capChargingStateAvailability.description)
213
+                            .font(.caption)
214
+                            .foregroundColor(.secondary)
215
+                    }
216
+                } else {
217
+                    Picker("Session Modes", selection: $chargingStateAvailability) {
218
+                        ForEach(ChargingStateAvailability.allCases) { availability in
219
+                            Text(availability.title).tag(availability)
220
+                        }
221
+                    }
222
+                }
223
+            } else if let enforcedChargingStateAvailability = deviceClass.enforcedChargingStateAvailability {
159 224
                 VStack(alignment: .leading, spacing: 6) {
160 225
                     Label(enforcedChargingStateAvailability.title, systemImage: "lock.fill")
161 226
                         .font(.subheadline.weight(.semibold))
@@ -166,8 +231,7 @@ struct ChargedDeviceEditorSheetView: View {
166 231
             } else {
167 232
                 Picker("Session Modes", selection: $chargingStateAvailability) {
168 233
                     ForEach(ChargingStateAvailability.allCases) { availability in
169
-                        Text(availability.title)
170
-                            .tag(availability)
234
+                        Text(availability.title).tag(availability)
171 235
                     }
172 236
                 }
173 237
             }
@@ -181,7 +245,9 @@ struct ChargedDeviceEditorSheetView: View {
181 245
                 message: "Enable the charging methods this device actually supports. Wireless devices can also keep a dedicated profile so learning stays separate."
182 246
             )
183 247
         ) {
184
-            if let enforcedChargingSupport = deviceClass.enforcedChargingSupport {
248
+            if let profile = selectedProfile {
249
+                profileChargingSupportRows(for: profile)
250
+            } else if let enforcedChargingSupport = deviceClass.enforcedChargingSupport {
185 251
                 VStack(alignment: .leading, spacing: 6) {
186 252
                     Label(
187 253
                         Self.chargingSupportDescription(
@@ -203,12 +269,10 @@ struct ChargedDeviceEditorSheetView: View {
203 269
 
204 270
             if showsWirelessProfilePicker {
205 271
                 Picker("Wireless profile", selection: $wirelessChargingProfile) {
206
-                    ForEach(WirelessChargingProfile.allCases) { profile in
207
-                        Text(profile.title)
208
-                            .tag(profile)
272
+                    ForEach(availableWirelessProfiles) { profile in
273
+                        Text(profile.title).tag(profile)
209 274
                     }
210 275
                 }
211
-
212 276
             }
213 277
 
214 278
             if supportedChargingModes.isEmpty {
@@ -219,6 +283,43 @@ struct ChargedDeviceEditorSheetView: View {
219 283
         }
220 284
     }
221 285
 
286
+    @ViewBuilder
287
+    private func profileChargingSupportRows(for profile: DeviceProfileDefinition) -> some View {
288
+        if DeviceProfileValidator.allowsTransportChoice(profile) {
289
+            Toggle("Supports wired charging", isOn: $supportsWiredCharging)
290
+            Toggle("Supports wireless charging", isOn: $supportsWirelessCharging)
291
+        } else {
292
+            VStack(alignment: .leading, spacing: 6) {
293
+                Label(
294
+                    Self.chargingSupportDescription(
295
+                        supportsWiredCharging: profile.capWiredCharging,
296
+                        supportsWirelessCharging: profile.capWirelessCharging
297
+                    ),
298
+                    systemImage: "lock.fill"
299
+                )
300
+                .font(.subheadline.weight(.semibold))
301
+
302
+                Text("This profile only allows one charging transport.")
303
+                    .font(.caption)
304
+                    .foregroundColor(.secondary)
305
+            }
306
+        }
307
+    }
308
+
309
+    private func internalSubjectSection(for profile: DeviceProfileDefinition) -> some View {
310
+        Section(
311
+            header: ContextInfoHeader(
312
+                title: "Internal Subject",
313
+                message: "Charging cases (like AirPods) hold a removable subject. Toggle on while the subject is inside; off when the case is empty."
314
+            )
315
+        ) {
316
+            Toggle("Subject is inside", isOn: $hasInternalSubject)
317
+            Text("When off, sessions reflect only the case's own battery and parasitic load (e.g. BLE advertising).")
318
+                .font(.caption)
319
+                .foregroundColor(.secondary)
320
+        }
321
+    }
322
+
222 323
     private var deviceCompletionSection: some View {
223 324
         Section(
224 325
             header: ContextInfoHeader(
@@ -251,6 +352,8 @@ struct ChargedDeviceEditorSheetView: View {
251 352
         }
252 353
     }
253 354
 
355
+    // MARK: - Computed
356
+
254 357
     private var editorTitle: String {
255 358
         chargedDevice == nil ? "New Device" : "Edit Device"
256 359
     }
@@ -265,52 +368,52 @@ struct ChargedDeviceEditorSheetView: View {
265 368
             && !hasInvalidCompletionCurrentEntry
266 369
     }
267 370
 
268
-    private var availableTemplates: [ChargedDeviceTemplateDefinition] {
269
-        ChargedDeviceTemplateCatalog.shared.templates(for: .device)
371
+    private var availableProfiles: [DeviceProfileDefinition] {
372
+        DeviceProfileCatalog.shared.profiles.filter { $0.category.kind == .device }
270 373
     }
271 374
 
272
-    private var groupedTemplates: [(group: String, templates: [ChargedDeviceTemplateDefinition])] {
273
-        Dictionary(grouping: availableTemplates, by: \.group)
375
+    private var groupedProfiles: [(group: String, profiles: [DeviceProfileDefinition])] {
376
+        Dictionary(grouping: availableProfiles, by: \.group)
274 377
             .keys
275 378
             .sorted()
276 379
             .map { group in
277
-                (
278
-                    group: group,
279
-                    templates: availableTemplates.filter { $0.group == group }
280
-                )
380
+                (group: group, profiles: availableProfiles.filter { $0.group == group })
281 381
             }
282 382
     }
283 383
 
284
-    private var selectedTemplate: ChargedDeviceTemplateDefinition? {
285
-        ChargedDeviceTemplateCatalog.shared.template(id: selectedTemplateID)
384
+    private var selectedProfile: DeviceProfileDefinition? {
385
+        DeviceProfileCatalog.shared.profile(id: selectedProfileID)
286 386
     }
287 387
 
288 388
     private var supportedChargingModes: [ChargingTransportMode] {
289 389
         var modes: [ChargingTransportMode] = []
290
-        if supportsWiredCharging {
291
-            modes.append(.wired)
292
-        }
293
-        if supportsWirelessCharging {
294
-            modes.append(.wireless)
295
-        }
390
+        if supportsWiredCharging { modes.append(.wired) }
391
+        if supportsWirelessCharging { modes.append(.wireless) }
296 392
         return modes
297 393
     }
298 394
 
299 395
     private var applicableSessionKinds: [ChargeSessionKind] {
300
-        supportedChargingModes.flatMap { chargingTransportMode in
301
-            chargingStateAvailability.supportedModes.map { chargingStateMode in
302
-                ChargeSessionKind(
303
-                    chargingTransportMode: chargingTransportMode,
304
-                    chargingStateMode: chargingStateMode
305
-                )
396
+        supportedChargingModes.flatMap { transportMode in
397
+            chargingStateAvailability.supportedModes.map { stateMode in
398
+                ChargeSessionKind(chargingTransportMode: transportMode, chargingStateMode: stateMode)
306 399
             }
307 400
         }
308 401
     }
309 402
 
403
+    private var availableWirelessProfiles: [WirelessChargingProfile] {
404
+        if let profile = selectedProfile, !profile.capWirelessProfiles.isEmpty {
405
+            return profile.capWirelessProfiles
406
+        }
407
+        return WirelessChargingProfile.allCases
408
+    }
409
+
310 410
     private var showsWirelessProfilePicker: Bool {
311
-        supportsWirelessCharging
312
-            && deviceClass != .watch
313
-            && supportedChargingModes.count > 1
411
+        guard supportsWirelessCharging else { return false }
412
+        if let profile = selectedProfile {
413
+            return DeviceProfileValidator.allowsWirelessProfileChoice(profile)
414
+                && supportedChargingModes.count > 1
415
+        }
416
+        return deviceClass != .watch && supportedChargingModes.count > 1
314 417
     }
315 418
 
316 419
     private var parsedCompletionCurrents: [ChargeSessionKind: Double] {
@@ -337,16 +440,32 @@ struct ChargedDeviceEditorSheetView: View {
337 440
         )
338 441
     }
339 442
 
443
+    // MARK: - Save & application
444
+
340 445
     private func save() {
341 446
         let configuredCompletionCurrents = parsedCompletionCurrents
447
+        let resolvedDeviceClass: ChargedDeviceClass
448
+        let resolvedTemplateID: String?
449
+
450
+        if let profile = selectedProfile {
451
+            // Derive legacy fields from the profile so Phase 1 readers still work.
452
+            resolvedDeviceClass = mapCategoryToLegacyClass(profile.category)
453
+            resolvedTemplateID = profile.id
454
+        } else {
455
+            resolvedDeviceClass = deviceClass
456
+            resolvedTemplateID = chargedDevice?.deviceTemplateID
457
+        }
458
+
342 459
         let didSave: Bool
343 460
 
344 461
         if let chargedDevice {
345 462
             didSave = appData.updateDevice(
346 463
                 id: chargedDevice.id,
347 464
                 name: name,
348
-                deviceClass: deviceClass,
349
-                templateID: selectedTemplateID,
465
+                deviceClass: resolvedDeviceClass,
466
+                templateID: resolvedTemplateID,
467
+                profileID: selectedProfileID,
468
+                hasInternalSubject: hasInternalSubject,
350 469
                 chargingStateAvailability: chargingStateAvailability,
351 470
                 supportsWiredCharging: supportsWiredCharging,
352 471
                 supportsWirelessCharging: supportsWirelessCharging,
@@ -357,15 +476,16 @@ struct ChargedDeviceEditorSheetView: View {
357 476
         } else {
358 477
             didSave = appData.createDevice(
359 478
                 name: name,
360
-                deviceClass: deviceClass,
361
-                templateID: selectedTemplateID,
479
+                deviceClass: resolvedDeviceClass,
480
+                templateID: resolvedTemplateID,
481
+                profileID: selectedProfileID,
482
+                hasInternalSubject: hasInternalSubject,
362 483
                 chargingStateAvailability: chargingStateAvailability,
363 484
                 supportsWiredCharging: supportsWiredCharging,
364 485
                 supportsWirelessCharging: supportsWirelessCharging,
365 486
                 wirelessChargingProfile: wirelessChargingProfile,
366 487
                 configuredCompletionCurrents: configuredCompletionCurrents,
367
-                notes: notes,
368
-                meterMACAddress: meterMACAddress
488
+                notes: notes
369 489
             )
370 490
         }
371 491
 
@@ -374,48 +494,39 @@ struct ChargedDeviceEditorSheetView: View {
374 494
         }
375 495
     }
376 496
 
377
-    private func applyTemplateSelection(
378
-        previousTemplateID: String?,
379
-        newTemplateID: String?
497
+    private func applyProfileSelection(
498
+        previousProfileID: String?,
499
+        newProfileID: String?
380 500
     ) {
381
-        guard let newTemplate = ChargedDeviceTemplateCatalog.shared.template(id: newTemplateID) else {
501
+        let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
502
+        let previousProfile = DeviceProfileCatalog.shared.profile(id: previousProfileID)
503
+
504
+        guard let newProfile = DeviceProfileCatalog.shared.profile(id: newProfileID) else {
505
+            // Switched to "Custom" — keep current state, fall back to legacy class rules.
506
+            if !trimmedName.isEmpty, trimmedName == previousProfile?.name {
507
+                name = ""
508
+            }
509
+            applyDeviceClassRulesIfCustom(for: deviceClass)
382 510
             return
383 511
         }
384 512
 
385
-        let previousTemplate = ChargedDeviceTemplateCatalog.shared.template(id: previousTemplateID)
386
-        let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
387
-        if trimmedName.isEmpty || trimmedName == previousTemplate?.name {
388
-            name = newTemplate.name
513
+        if trimmedName.isEmpty || trimmedName == previousProfile?.name {
514
+            name = newProfile.name
389 515
         }
390 516
 
391
-        deviceClass = newTemplate.deviceClass
392
-        chargingStateAvailability = newTemplate.deviceClass.normalizedChargingStateAvailability(
393
-            newTemplate.chargingStateAvailability
394
-        )
395
-
396
-        let normalizedChargingSupport = newTemplate.deviceClass.normalizedChargingSupport(
397
-            supportsWiredCharging: newTemplate.supportsWiredCharging,
398
-            supportsWirelessCharging: newTemplate.supportsWirelessCharging
399
-        )
400
-        supportsWiredCharging = normalizedChargingSupport.wired
401
-        supportsWirelessCharging = normalizedChargingSupport.wireless
402
-        wirelessChargingProfile = newTemplate.wirelessChargingProfile
517
+        let canonical = DeviceProfileValidator.canonicalState(for: newProfile)
518
+        chargingStateAvailability = canonical.chargingStateAvailability
519
+        supportsWiredCharging = canonical.supportsWiredCharging
520
+        supportsWirelessCharging = canonical.supportsWirelessCharging
521
+        wirelessChargingProfile = canonical.wirelessChargingProfile
522
+        if !newProfile.capHasInternalSubject {
523
+            hasInternalSubject = false
524
+        }
525
+        deviceClass = mapCategoryToLegacyClass(newProfile.category)
403 526
     }
404 527
 
405
-    private func applyDeviceClassRules(for deviceClass: ChargedDeviceClass) {
406
-        if let selectedTemplate {
407
-            chargingStateAvailability = deviceClass.normalizedChargingStateAvailability(
408
-                selectedTemplate.chargingStateAvailability
409
-            )
410
-            let normalizedChargingSupport = deviceClass.normalizedChargingSupport(
411
-                supportsWiredCharging: selectedTemplate.supportsWiredCharging,
412
-                supportsWirelessCharging: selectedTemplate.supportsWirelessCharging
413
-            )
414
-            supportsWiredCharging = normalizedChargingSupport.wired
415
-            supportsWirelessCharging = normalizedChargingSupport.wireless
416
-            wirelessChargingProfile = selectedTemplate.wirelessChargingProfile
417
-            return
418
-        }
528
+    private func applyDeviceClassRulesIfCustom(for deviceClass: ChargedDeviceClass) {
529
+        guard selectedProfile == nil else { return }
419 530
 
420 531
         if let enforcedChargingStateAvailability = deviceClass.enforcedChargingStateAvailability {
421 532
             chargingStateAvailability = enforcedChargingStateAvailability
@@ -433,16 +544,23 @@ struct ChargedDeviceEditorSheetView: View {
433 544
         }
434 545
     }
435 546
 
547
+    private func mapCategoryToLegacyClass(_ category: ProfileCategory) -> ChargedDeviceClass {
548
+        switch category {
549
+        case .phone: return .iphone
550
+        case .watch: return .watch
551
+        case .powerbank: return .powerbank
552
+        case .charger: return .charger
553
+        case .tablet, .laptop, .audioAccessory, .accessoryCase, .other:
554
+            return .other
555
+        }
556
+    }
557
+
436 558
     private func parsedOptionalCurrent(_ text: String) -> Double? {
437 559
         let normalized = text
438 560
             .trimmingCharacters(in: .whitespacesAndNewlines)
439 561
             .replacingOccurrences(of: ",", with: ".")
440
-        guard !normalized.isEmpty else {
441
-            return nil
442
-        }
443
-        guard let value = Double(normalized), value > 0 else {
444
-            return nil
445
-        }
562
+        guard !normalized.isEmpty else { return nil }
563
+        guard let value = Double(normalized), value > 0 else { return nil }
446 564
         return value
447 565
     }
448 566
 
@@ -462,11 +580,23 @@ struct ChargedDeviceEditorSheetView: View {
462 580
         }
463 581
     }
464 582
 
465
-    private static func makeCompletionCurrentTexts(for chargedDevice: ChargedDeviceSummary?) -> [ChargeSessionKind: String] {
466
-        guard let chargedDevice else {
467
-            return [:]
583
+    // MARK: - Helpers
584
+
585
+    private static func resolveInitialProfileID(for chargedDevice: ChargedDeviceSummary?) -> String? {
586
+        guard let chargedDevice else { return nil }
587
+        if let profileID = chargedDevice.profileID,
588
+           DeviceProfileCatalog.shared.profile(id: profileID) != nil {
589
+            return profileID
468 590
         }
591
+        if let templateID = chargedDevice.deviceTemplateID,
592
+           DeviceProfileCatalog.shared.profile(id: templateID) != nil {
593
+            return templateID
594
+        }
595
+        return nil
596
+    }
469 597
 
598
+    private static func makeCompletionCurrentTexts(for chargedDevice: ChargedDeviceSummary?) -> [ChargeSessionKind: String] {
599
+        guard let chargedDevice else { return [:] }
470 600
         return ChargeSessionKind.allCases.reduce(into: [ChargeSessionKind: String]()) { result, sessionKind in
471 601
             result[sessionKind] = optionalCurrentText(
472 602
                 chargedDevice.configuredCompletionCurrentAmps(for: sessionKind)
@@ -475,9 +605,7 @@ struct ChargedDeviceEditorSheetView: View {
475 605
     }
476 606
 
477 607
     private static func optionalCurrentText(_ value: Double?) -> String {
478
-        guard let value else {
479
-            return ""
480
-        }
608
+        guard let value else { return "" }
481 609
         return value.format(decimalDigits: 2)
482 610
     }
483 611
 
@@ -486,15 +614,10 @@ struct ChargedDeviceEditorSheetView: View {
486 614
         supportsWirelessCharging: Bool
487 615
     ) -> String {
488 616
         switch (supportsWiredCharging, supportsWirelessCharging) {
489
-        case (true, true):
490
-            return "Supports wired and wireless charging"
491
-        case (true, false):
492
-            return "Supports wired charging only"
493
-        case (false, true):
494
-            return "Supports wireless charging only"
495
-        case (false, false):
496
-            return "No charging method configured"
617
+        case (true, true): return "Supports wired and wireless charging"
618
+        case (true, false): return "Supports wired charging only"
619
+        case (false, true): return "Supports wireless charging only"
620
+        case (false, false): return "No charging method configured"
497 621
         }
498 622
     }
499
-
500 623
 }
+7 -35
USB Meter/Views/ChargedDevices/Sheets/Library/ChargedDeviceLibrarySheetView.swift
@@ -33,7 +33,6 @@ struct ChargedDeviceLibrarySheetView: View {
33 33
     @EnvironmentObject private var appData: AppData
34 34
     @Environment(\.dismiss) private var dismiss
35 35
 
36
-    let meterMACAddress: String
37 36
     let meterTint: Color
38 37
     let mode: ChargedDeviceLibraryMode
39 38
     /// true = standalone sheet with own NavigationView; false = pushed into parent nav stack
@@ -44,12 +43,10 @@ struct ChargedDeviceLibrarySheetView: View {
44 43
     @State private var pendingDeletion: ChargedDeviceSummary?
45 44
 
46 45
     init(
47
-        meterMACAddress: String,
48 46
         meterTint: Color,
49 47
         mode: ChargedDeviceLibraryMode,
50 48
         standalone: Bool = true
51 49
     ) {
52
-        self.meterMACAddress = meterMACAddress
53 50
         self.meterTint = meterTint
54 51
         self.mode = mode
55 52
         self.standalone = standalone
@@ -81,16 +78,12 @@ struct ChargedDeviceLibrarySheetView: View {
81 78
                 .listRowBackground(Color.clear)
82 79
             } else {
83 80
                 ForEach(displayedChargedDevices) { chargedDevice in
84
-                    Button {
85
-                        select(chargedDevice)
86
-                        dismiss()
87
-                    } label: {
81
+                    NavigationLink(destination: ChargedDeviceSettingsView(chargedDeviceID: chargedDevice.id)) {
88 82
                         ChargedDeviceLibraryRowView(
89 83
                             chargedDevice: chargedDevice,
90
-                            isSelected: chargedDevice.id == selectedDeviceID
84
+                            isSelected: false
91 85
                         )
92 86
                     }
93
-                    .buttonStyle(.plain)
94 87
                     .swipeActions(edge: .trailing, allowsFullSwipe: false) {
95 88
                         Button(role: .destructive) {
96 89
                             pendingDeletion = chargedDevice
@@ -169,10 +162,10 @@ struct ChargedDeviceLibrarySheetView: View {
169 162
     @ViewBuilder
170 163
     private var newEditorSheet: some View {
171 164
         if mode == .charger {
172
-            ChargerEditorSheetView(meterMACAddress: meterMACAddress)
165
+            ChargerEditorSheetView()
173 166
                 .environmentObject(appData)
174 167
         } else {
175
-            ChargedDeviceEditorSheetView(meterMACAddress: meterMACAddress)
168
+            ChargedDeviceEditorSheetView()
176 169
                 .environmentObject(appData)
177 170
         }
178 171
     }
@@ -183,10 +176,7 @@ struct ChargedDeviceLibrarySheetView: View {
183 176
             ChargerEditorSheetView(chargedDevice: chargedDevice)
184 177
                 .environmentObject(appData)
185 178
         } else {
186
-            ChargedDeviceEditorSheetView(
187
-                meterMACAddress: nil,
188
-                chargedDevice: chargedDevice
189
-            )
179
+            ChargedDeviceEditorSheetView(chargedDevice: chargedDevice)
190 180
             .environmentObject(appData)
191 181
         }
192 182
     }
@@ -200,30 +190,12 @@ struct ChargedDeviceLibrarySheetView: View {
200 190
         }
201 191
     }
202 192
 
203
-    private var selectedDeviceID: UUID? {
204
-        switch mode {
205
-        case .device:
206
-            return appData.currentChargedDeviceSummary(for: meterMACAddress)?.id
207
-        case .charger:
208
-            return appData.currentChargerSummary(for: meterMACAddress)?.id
209
-        }
210
-    }
211
-
212 193
     private var emptyStateDescription: String {
213 194
         switch mode {
214 195
         case .device:
215
-            return "Create one here, then select it before or during a charging session. The selected device becomes the default for this meter."
216
-        case .charger:
217
-            return "Create one here, then select it for wireless charging sessions. The selected charger becomes the default wireless source for this meter."
218
-        }
219
-    }
220
-
221
-    private func select(_ chargedDevice: ChargedDeviceSummary) {
222
-        switch mode {
223
-        case .device:
224
-            appData.assignChargedDevice(chargedDevice.id, to: meterMACAddress)
196
+            return "Create one here, then select it explicitly when starting a charging session."
225 197
         case .charger:
226
-            appData.assignCharger(chargedDevice.id, to: meterMACAddress)
198
+            return "Create one here, then select it explicitly for wireless charging sessions or standby measurements."
227 199
         }
228 200
     }
229 201
 }
+1 -5
USB Meter/Views/ChargedDevices/Sheets/Editors/ChargerEditorSheetView.swift → USB Meter/Views/Chargers/ChargerEditorSheetView.swift
@@ -10,7 +10,6 @@ struct ChargerEditorSheetView: View {
10 10
     @Environment(\.dismiss) private var dismiss
11 11
 
12 12
     let chargedDevice: ChargedDeviceSummary?
13
-    let meterMACAddress: String?
14 13
     /// When false the view omits its own NavigationView (used as a push destination).
15 14
     let standalone: Bool
16 15
 
@@ -20,11 +19,9 @@ struct ChargerEditorSheetView: View {
20 19
 
21 20
     init(
22 21
         chargedDevice: ChargedDeviceSummary? = nil,
23
-        meterMACAddress: String? = nil,
24 22
         standalone: Bool = true
25 23
     ) {
26 24
         self.chargedDevice = chargedDevice
27
-        self.meterMACAddress = meterMACAddress
28 25
         self.standalone = standalone
29 26
         _name = State(initialValue: chargedDevice?.name ?? "")
30 27
         _chargerType = State(initialValue: chargedDevice?.chargerType ?? .genericQi)
@@ -99,8 +96,7 @@ struct ChargerEditorSheetView: View {
99 96
             didSave = appData.createCharger(
100 97
                 name: name,
101 98
                 chargerType: chargerType,
102
-                notes: notesValue,
103
-                meterMACAddress: meterMACAddress
99
+                notes: notesValue
104 100
             )
105 101
         }
106 102
 
+4 -11
USB Meter/Views/Meter/Tabs/Live/ChargerStandbyPowerWizardView.swift → USB Meter/Views/Chargers/ChargerStandbyPowerWizardView.swift
@@ -53,9 +53,9 @@ struct ChargerStandbyPowerWizardView: View {
53 53
             .ignoresSafeArea()
54 54
         )
55 55
         .navigationTitle(navigationTitleText)
56
+        .navigationBarTitleDisplayMode(.inline)
56 57
         .sheet(isPresented: $chargerLibraryVisibility) {
57 58
             ChargedDeviceLibrarySheetView(
58
-                meterMACAddress: selectedMeterSummary?.macAddress ?? "",
59 59
                 meterTint: selectedMeter?.color ?? .orange,
60 60
                 mode: .charger
61 61
             )
@@ -85,15 +85,10 @@ struct ChargerStandbyPowerWizardView: View {
85 85
         appData.chargerSummaries
86 86
     }
87 87
 
88
-    private var preferredChargerMeterMACAddress: String? {
89
-        preferredChargerID.flatMap { appData.chargedDeviceSummary(id: $0)?.lastAssociatedMeterMAC }
90
-    }
91
-
92 88
     private var activeSession: ChargerStandbyPowerMonitorSession? {
93 89
         let candidateMACAddresses = [
94 90
             selectedMeterMACAddress ?? "",
95
-            preferredMeterMACAddress ?? "",
96
-            preferredChargerMeterMACAddress ?? ""
91
+            preferredMeterMACAddress ?? ""
97 92
         ]
98 93
         .filter { $0.isEmpty == false }
99 94
 
@@ -117,10 +112,6 @@ struct ChargerStandbyPowerWizardView: View {
117 112
             return liveMeterSummaries.first(where: { $0.macAddress == preferredMeterMACAddress })
118 113
         }
119 114
 
120
-        if let preferredChargerMeterMACAddress {
121
-            return liveMeterSummaries.first(where: { $0.macAddress == preferredChargerMeterMACAddress })
122
-        }
123
-
124 115
         return liveMeterSummaries.count == 1 ? liveMeterSummaries.first : nil
125 116
     }
126 117
 
@@ -699,6 +690,7 @@ struct ChargerStandbyPowerMeasurementsView: View {
699 690
                 Text("This charger is no longer available.")
700 691
                     .foregroundColor(.secondary)
701 692
                     .navigationTitle("Saved Measurements")
693
+                    .navigationBarTitleDisplayMode(.inline)
702 694
             }
703 695
         }
704 696
     }
@@ -752,6 +744,7 @@ struct ChargerStandbyPowerMeasurementsView: View {
752 744
         }
753 745
         .environment(\.editMode, $editMode)
754 746
         .navigationTitle("Saved Measurements")
747
+        .navigationBarTitleDisplayMode(.inline)
755 748
         .toolbar {
756 749
             ToolbarItem(placement: .primaryAction) {
757 750
                 Button(editMode.isEditing ? "Done" : "Select") {
+31 -0
USB Meter/Views/Components/Generic/SidebarToggleToolbar.swift
@@ -0,0 +1,31 @@
1
+//
2
+//  SidebarToggleToolbar.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+extension View {
9
+    /// Adds a sidebar toggle button to the leading toolbar on Mac Catalyst.
10
+    /// No-op on other platforms. Apply to any top-level detail view reachable
11
+    /// from the sidebar so users can restore the sidebar if it was hidden.
12
+    func sidebarToggleToolbarItem() -> some View {
13
+        #if targetEnvironment(macCatalyst)
14
+        return self.toolbar {
15
+            ToolbarItem(placement: .navigationBarLeading) {
16
+                Button {
17
+                    UIApplication.shared.sendAction(
18
+                        Selector(("toggleSidebar:")),
19
+                        to: nil, from: nil, for: nil
20
+                    )
21
+                } label: {
22
+                    Image(systemName: "sidebar.left")
23
+                }
24
+                .help("Show Sidebar")
25
+            }
26
+        }
27
+        #else
28
+        return self
29
+        #endif
30
+    }
31
+}
+2 -0
USB Meter/Views/DeviceHelpView.swift
@@ -43,6 +43,8 @@ struct DeviceHelpView: View {
43 43
             .ignoresSafeArea()
44 44
         )
45 45
         .navigationTitle("Device Help")
46
+        .navigationBarTitleDisplayMode(.inline)
47
+        .sidebarToggleToolbarItem()
46 48
     }
47 49
 
48 50
     private func helpCard(title: String, body: String) -> some View {
+687 -161
USB Meter/Views/Meter/Components/MeasurementChartView.swift
@@ -7,6 +7,7 @@
7 7
 //
8 8
 
9 9
 import SwiftUI
10
+import UniformTypeIdentifiers
10 11
 
11 12
 private enum PresentTrackingMode: CaseIterable, Hashable {
12 13
     case keepDuration
@@ -74,10 +75,66 @@ struct MeasurementChartResetAction {
74 75
     }
75 76
 }
76 77
 
78
+struct MeasurementChartExportAction {
79
+    let title: String
80
+    let shortTitle: String?
81
+    let systemName: String
82
+    let tone: MeasurementChartSelectorActionTone
83
+    let fileName: (ClosedRange<Date>) -> String
84
+    let content: (ClosedRange<Date>) -> String
85
+
86
+    init(
87
+        title: String,
88
+        shortTitle: String? = nil,
89
+        systemName: String,
90
+        tone: MeasurementChartSelectorActionTone,
91
+        fileName: @escaping (ClosedRange<Date>) -> String,
92
+        content: @escaping (ClosedRange<Date>) -> String
93
+    ) {
94
+        self.title = title
95
+        self.shortTitle = shortTitle
96
+        self.systemName = systemName
97
+        self.tone = tone
98
+        self.fileName = fileName
99
+        self.content = content
100
+    }
101
+}
102
+
77 103
 struct MeasurementChartRangeSelectorConfiguration {
78 104
     let keepAction: MeasurementChartSelectionAction
79 105
     let removeAction: MeasurementChartSelectionAction?
80 106
     let resetAction: MeasurementChartResetAction
107
+    let exportAction: MeasurementChartExportAction?
108
+
109
+    init(
110
+        keepAction: MeasurementChartSelectionAction,
111
+        removeAction: MeasurementChartSelectionAction?,
112
+        resetAction: MeasurementChartResetAction,
113
+        exportAction: MeasurementChartExportAction? = nil
114
+    ) {
115
+        self.keepAction = keepAction
116
+        self.removeAction = removeAction
117
+        self.resetAction = resetAction
118
+        self.exportAction = exportAction
119
+    }
120
+}
121
+
122
+private struct MeasurementChartCSVDocument: FileDocument {
123
+    static var readableContentTypes: [UTType] { [.commaSeparatedText] }
124
+
125
+    var content: String
126
+
127
+    init(content: String) {
128
+        self.content = content
129
+    }
130
+
131
+    init(configuration: ReadConfiguration) throws {
132
+        content = ""
133
+    }
134
+
135
+    func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
136
+        FileWrapper(regularFileWithContents: Data(content.utf8))
137
+    }
81 138
 }
82 139
 
83 140
 struct MeasurementChartView: View {
@@ -115,12 +172,24 @@ struct MeasurementChartView: View {
115 172
         }
116 173
     }
117 174
 
118
-    private enum SeriesKind {
175
+    private enum SeriesKind: Hashable {
119 176
         case power
120 177
         case energy
121 178
         case voltage
122 179
         case current
123 180
         case temperature
181
+        case batteryPercent
182
+
183
+        var displayName: String {
184
+            switch self {
185
+            case .power: return "Power"
186
+            case .energy: return "Energy"
187
+            case .voltage: return "Voltage"
188
+            case .current: return "Current"
189
+            case .temperature: return "Temperature"
190
+            case .batteryPercent: return "Battery"
191
+            }
192
+        }
124 193
 
125 194
         var unit: String {
126 195
             switch self {
@@ -129,6 +198,7 @@ struct MeasurementChartView: View {
129 198
             case .voltage: return "V"
130 199
             case .current: return "A"
131 200
             case .temperature: return ""
201
+            case .batteryPercent: return "%"
132 202
             }
133 203
         }
134 204
 
@@ -139,6 +209,7 @@ struct MeasurementChartView: View {
139 209
             case .voltage: return .green
140 210
             case .current: return .blue
141 211
             case .temperature: return .orange
212
+            case .batteryPercent: return .mint
142 213
             }
143 214
         }
144 215
     }
@@ -153,12 +224,51 @@ struct MeasurementChartView: View {
153 224
         let maximumSampleValue: Double?
154 225
     }
155 226
 
227
+    private enum LegendStatistic: CaseIterable, Hashable {
228
+        case minimum
229
+        case average
230
+        case maximum
231
+        case last
232
+        case total
233
+
234
+        var title: String {
235
+            switch self {
236
+            case .minimum: return "Min"
237
+            case .average: return "Avg"
238
+            case .maximum: return "Max"
239
+            case .last: return "Last"
240
+            case .total: return "Total"
241
+            }
242
+        }
243
+    }
244
+
245
+    private struct SeriesLegendValue: Identifiable {
246
+        let statistic: LegendStatistic
247
+        let text: String
248
+
249
+        var id: LegendStatistic {
250
+            statistic
251
+        }
252
+    }
253
+
254
+    private struct SeriesLegendEntry: Identifiable {
255
+        let id: SeriesKind
256
+        let name: String
257
+        let tint: Color
258
+        let values: [SeriesLegendValue]
259
+
260
+        func text(for statistic: LegendStatistic) -> String? {
261
+            values.first { $0.statistic == statistic }?.text
262
+        }
263
+    }
264
+
156 265
     private let minimumTimeSpan: TimeInterval = 1
157 266
     private let minimumVoltageSpan = 0.5
158 267
     private let minimumCurrentSpan = 0.5
159 268
     private let minimumPowerSpan = 0.5
160 269
     private let minimumEnergySpan = 0.1
161 270
     private let minimumTemperatureSpan = 1.0
271
+    private let minimumBatteryPercentSpan = 10.0
162 272
     private let defaultEmptyChartTimeSpan: TimeInterval = 60
163 273
     private let selectorTint: Color = .blue
164 274
 
@@ -167,6 +277,9 @@ struct MeasurementChartView: View {
167 277
     let rebasesEnergyToVisibleRangeStart: Bool
168 278
     let extendsTimelineToPresent: Bool
169 279
     let showsTemperatureSeries: Bool
280
+    let showsBatteryPercentSeries: Bool
281
+    let batteryCheckpoints: [ChargeCheckpointSummary]
282
+    let batteryPercentPoints: [Measurements.Measurement.Point]
170 283
     let rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration?
171 284
 
172 285
     @EnvironmentObject private var measurements: Measurements
@@ -199,6 +312,7 @@ struct MeasurementChartView: View {
199 312
     @State var displayPower: Bool = true
200 313
     @State var displayEnergy: Bool = false
201 314
     @State var displayTemperature: Bool = false
315
+    @State private var displayBatteryPercent: Bool = false
202 316
     @State private var smoothingLevel: SmoothingLevel = .off
203 317
     @State private var chartNow: Date = Date()
204 318
     @State private var selectedVisibleTimeRange: ClosedRange<Date>?
@@ -213,6 +327,7 @@ struct MeasurementChartView: View {
213 327
     @State private var voltageAxisOrigin: Double = 0
214 328
     @State private var currentAxisOrigin: Double = 0
215 329
     @State private var temperatureAxisOrigin: Double = 0
330
+    @State private var batteryPercentAxisOrigin: Double = 0
216 331
     let xLabels: Int = 4
217 332
     let yLabels: Int = 4
218 333
 
@@ -225,6 +340,9 @@ struct MeasurementChartView: View {
225 340
         rebasesEnergyToVisibleRangeStart: Bool = false,
226 341
         extendsTimelineToPresent: Bool = true,
227 342
         showsTemperatureSeries: Bool = true,
343
+        showsBatteryPercentSeries: Bool = false,
344
+        batteryCheckpoints: [ChargeCheckpointSummary] = [],
345
+        batteryPercentPoints: [Measurements.Measurement.Point] = [],
228 346
         rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration? = nil
229 347
     ) {
230 348
         self.sizing = sizing
@@ -235,14 +353,35 @@ struct MeasurementChartView: View {
235 353
         self.rebasesEnergyToVisibleRangeStart = rebasesEnergyToVisibleRangeStart
236 354
         self.extendsTimelineToPresent = extendsTimelineToPresent
237 355
         self.showsTemperatureSeries = showsTemperatureSeries
356
+        self.showsBatteryPercentSeries = showsBatteryPercentSeries
357
+        self.batteryCheckpoints = batteryCheckpoints
358
+        self.batteryPercentPoints = batteryPercentPoints
238 359
         self.rangeSelectorConfiguration = rangeSelectorConfiguration
360
+        _displayPower = State(initialValue: showsBatteryPercentSeries == false)
361
+        _displayBatteryPercent = State(initialValue: showsBatteryPercentSeries)
239 362
     }
240 363
 
241 364
     private static func embeddedContentHeight(width: CGFloat, showsRangeSelector: Bool) -> CGFloat {
242 365
         let compact = width < 760
243
-        let plotHeight: CGFloat = compact ? 290 : 350
244
-        guard showsRangeSelector else { return plotHeight }
245
-        return plotHeight + TimeRangeSelectorView.recommendedReservedHeight(compactLayout: compact)
366
+        let plotHeight: CGFloat = compact ? 240 : 300
367
+        let toolbarHeight: CGFloat = width < 640
368
+            ? (compact ? 92 : 104)
369
+            : (compact ? 48 : 56)
370
+        let legendHeight: CGFloat = compact ? 76 : 90
371
+        let outerSpacing: CGFloat = 12
372
+        let chartStackSpacing: CGFloat = compact ? 8 : 10
373
+        let selectorHeight = showsRangeSelector
374
+            ? TimeRangeSelectorView.recommendedReservedHeight(compactLayout: compact)
375
+            : 0
376
+        let selectorSpacing = showsRangeSelector ? chartStackSpacing : 0
377
+
378
+        return toolbarHeight
379
+            + outerSpacing
380
+            + plotHeight
381
+            + selectorSpacing
382
+            + selectorHeight
383
+            + chartStackSpacing
384
+            + legendHeight
246 385
     }
247 386
 
248 387
     private var axisColumnWidth: CGFloat {
@@ -263,16 +402,6 @@ struct MeasurementChartView: View {
263 402
         return isLargeDisplay ? 36 : 28
264 403
     }
265 404
 
266
-    private var belowXAxisControlsHeight: CGFloat {
267
-        if usesCompactLandscapeOriginControls {
268
-            return 40
269
-        }
270
-        if compactLayout {
271
-            return 46
272
-        }
273
-        return isLargeDisplay ? 58 : 50
274
-    }
275
-
276 405
     private var isPortraitLayout: Bool {
277 406
         guard availableSize != .zero else { return verticalSizeClass != .compact }
278 407
         return availableSize.height >= availableSize.width
@@ -286,20 +415,11 @@ struct MeasurementChartView: View {
286 415
         #endif
287 416
     }
288 417
 
289
-    private enum OriginControlsPlacement {
290
-        case aboveXAxisLegend
291
-        case overXAxisLegend
292
-        case belowXAxisLegend
293
-    }
294
-
295
-    private var originControlsPlacement: OriginControlsPlacement {
296
-        if isIPhone {
297
-            return isPortraitLayout ? .aboveXAxisLegend : .overXAxisLegend
418
+    private var plotSectionHeight: CGFloat {
419
+        if case .embedded = sizing {
420
+            return compactLayout ? 240 : 300
298 421
         }
299
-        return .belowXAxisLegend
300
-    }
301 422
 
302
-    private var plotSectionHeight: CGFloat {
303 423
         if availableSize == .zero {
304 424
             return compactLayout ? 300 : 380
305 425
         }
@@ -358,11 +478,10 @@ struct MeasurementChartView: View {
358 478
             switch sizing {
359 479
             case .provided:
360 480
                 chartBody
481
+                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
361 482
             case .embedded:
362
-                let chartWidth = max(embeddedWidth, 1)
363 483
                 chartBody
364 484
                     .frame(maxWidth: .infinity, alignment: .topLeading)
365
-                    .frame(height: Self.embeddedContentHeight(width: chartWidth, showsRangeSelector: showsRangeSelector))
366 485
                     .background(
367 486
                         GeometryReader { geometry in
368 487
                             Color.clear.preference(key: EmbeddedWidthKey.self, value: geometry.size.width)
@@ -374,10 +493,16 @@ struct MeasurementChartView: View {
374 493
                     }
375 494
             }
376 495
         }
377
-        .onAppear(perform: resetHiddenTemperatureDisplay)
496
+        .onAppear {
497
+            resetHiddenTemperatureDisplay()
498
+            resetHiddenBatteryPercentDisplay()
499
+        }
378 500
         .onChange(of: showsTemperatureSeries) { _ in
379 501
             resetHiddenTemperatureDisplay()
380 502
         }
503
+        .onChange(of: showsBatteryPercentSeries) { _ in
504
+            resetHiddenBatteryPercentDisplay()
505
+        }
381 506
     }
382 507
 
383 508
     private func resetHiddenTemperatureDisplay() {
@@ -385,6 +510,14 @@ struct MeasurementChartView: View {
385 510
         displayTemperature = false
386 511
     }
387 512
 
513
+    private func resetHiddenBatteryPercentDisplay() {
514
+        guard !showsBatteryPercentSeries, displayBatteryPercent else { return }
515
+        displayBatteryPercent = false
516
+        if !displayPower && !displayEnergy && !displayVoltage && !displayCurrent {
517
+            displayPower = true
518
+        }
519
+    }
520
+
388 521
     @ViewBuilder
389 522
     private var chartBody: some View {
390 523
         let availableTimeRange = availableSelectionTimeRange()
@@ -419,28 +552,35 @@ struct MeasurementChartView: View {
419 552
             minimumYSpan: minimumTemperatureSpan,
420 553
             visibleTimeRange: visibleTimeRange
421 554
         )
555
+        let batteryPercentSeries = series(
556
+            for: batteryPercentPoints.isEmpty ? measurements.batteryPercent.points : batteryPercentPoints,
557
+            kind: .batteryPercent,
558
+            minimumYSpan: minimumBatteryPercentSpan,
559
+            visibleTimeRange: visibleTimeRange
560
+        )
422 561
         let primarySeries = displayedPrimarySeries(
423 562
             powerSeries: powerSeries,
424 563
             energySeries: energySeries,
425 564
             voltageSeries: voltageSeries,
426
-            currentSeries: currentSeries
565
+            currentSeries: currentSeries,
566
+            batteryPercentSeries: batteryPercentSeries
427 567
         )
428 568
         let selectorSeries = primarySeries.map { overviewSeries(for: $0.kind) }
429 569
 
430 570
         Group {
431 571
             if let primarySeries {
432 572
                 VStack(alignment: .leading, spacing: 12) {
433
-                    chartToggleBar()
573
+                    chartTopToolbar(
574
+                        voltageSeries: voltageSeries,
575
+                        currentSeries: currentSeries
576
+                    )
434 577
 
435 578
                     VStack(spacing: compactLayout ? 8 : 10) {
436 579
                         GeometryReader { geometry in
437
-                            let reservedBottomHeight =
438
-                                xAxisHeight
439
-                                + (originControlsPlacement == .belowXAxisLegend ? belowXAxisControlsHeight + 6 : 0)
440
-                            let plotHeight = max(
441
-                                geometry.size.height - reservedBottomHeight,
442
-                                compactLayout ? 180 : 220
443
-                            )
580
+                            let minimumPlotHeight: CGFloat = compactLayout
581
+                                ? (isPortraitLayout ? 180 : 120)
582
+                                : 220
583
+                            let plotHeight = max(geometry.size.height - xAxisHeight, minimumPlotHeight)
444 584
 
445 585
                             VStack(spacing: 6) {
446 586
                                 HStack(spacing: chartSectionSpacing) {
@@ -449,7 +589,8 @@ struct MeasurementChartView: View {
449 589
                                         powerSeries: powerSeries,
450 590
                                         energySeries: energySeries,
451 591
                                         voltageSeries: voltageSeries,
452
-                                        currentSeries: currentSeries
592
+                                        currentSeries: currentSeries,
593
+                                        batteryPercentSeries: batteryPercentSeries
453 594
                                     )
454 595
                                     .frame(width: axisColumnWidth, height: plotHeight)
455 596
 
@@ -468,7 +609,8 @@ struct MeasurementChartView: View {
468 609
                                             energySeries: energySeries,
469 610
                                             voltageSeries: voltageSeries,
470 611
                                             currentSeries: currentSeries,
471
-                                            temperatureSeries: temperatureSeries
612
+                                            temperatureSeries: temperatureSeries,
613
+                                            batteryPercentSeries: batteryPercentSeries
472 614
                                         )
473 615
                                     }
474 616
                                     .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
@@ -481,52 +623,30 @@ struct MeasurementChartView: View {
481 623
                                         energySeries: energySeries,
482 624
                                         voltageSeries: voltageSeries,
483 625
                                         currentSeries: currentSeries,
484
-                                        temperatureSeries: temperatureSeries
626
+                                        temperatureSeries: temperatureSeries,
627
+                                        batteryPercentSeries: batteryPercentSeries
485 628
                                     )
486 629
                                     .frame(width: axisColumnWidth, height: plotHeight)
487 630
                                 }
488
-                                .overlay(alignment: .bottom) {
489
-                                    if originControlsPlacement == .aboveXAxisLegend {
490
-                                        scaleControlsPill(
491
-                                            voltageSeries: voltageSeries,
492
-                                            currentSeries: currentSeries
493
-                                        )
494
-                                        .padding(.bottom, compactLayout ? 6 : 10)
495
-                                    }
496
-                                }
497 631
 
498
-                                switch originControlsPlacement {
499
-                                case .aboveXAxisLegend:
500
-                                    xAxisLabelsView(context: primarySeries.context)
501
-                                        .frame(height: xAxisHeight)
502
-                                case .overXAxisLegend:
503
-                                    xAxisLabelsView(context: primarySeries.context)
504
-                                        .frame(height: xAxisHeight)
505
-                                        .overlay(alignment: .center) {
506
-                                            scaleControlsPill(
507
-                                                voltageSeries: voltageSeries,
508
-                                                currentSeries: currentSeries
509
-                                            )
510
-                                            .offset(y: usesCompactLandscapeOriginControls ? 2 : (compactLayout ? 8 : 10))
511
-                                        }
512
-                                case .belowXAxisLegend:
513
-                                    xAxisLabelsView(context: primarySeries.context)
514
-                                        .frame(height: xAxisHeight)
515
-
516
-                                    HStack {
517
-                                        Spacer(minLength: 0)
518
-                                        scaleControlsPill(
519
-                                            voltageSeries: voltageSeries,
520
-                                            currentSeries: currentSeries
521
-                                        )
522
-                                        Spacer(minLength: 0)
523
-                                    }
524
-                                }
632
+                                xAxisLabelsView(context: primarySeries.context)
633
+                                    .frame(height: xAxisHeight)
525 634
                             }
526 635
                             .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
527 636
                         }
528 637
                         .frame(height: plotSectionHeight)
529 638
 
639
+                        chartLegend(
640
+                            entries: chartLegendEntries(
641
+                                powerSeries: powerSeries,
642
+                                energySeries: energySeries,
643
+                                voltageSeries: voltageSeries,
644
+                                currentSeries: currentSeries,
645
+                                temperatureSeries: temperatureSeries,
646
+                                batteryPercentSeries: batteryPercentSeries
647
+                            )
648
+                        )
649
+
530 650
                         if showsRangeSelector,
531 651
                            let availableTimeRange,
532 652
                            let selectorSeries,
@@ -552,25 +672,31 @@ struct MeasurementChartView: View {
552 672
                 }
553 673
             } else {
554 674
                 VStack(alignment: .leading, spacing: 12) {
555
-                    chartToggleBar()
675
+                    chartTopToolbar(
676
+                        voltageSeries: voltageSeries,
677
+                        currentSeries: currentSeries
678
+                    )
556 679
                     Text("Select at least one measurement series.")
557 680
                         .foregroundColor(.secondary)
558 681
                 }
559 682
             }
560 683
         }
561 684
         .font(chartBaseFont)
562
-        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
685
+        .frame(maxWidth: .infinity, alignment: .topLeading)
563 686
         .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { now in
564 687
             guard extendsTimelineToPresent, timeRange == nil, timeRangeUpperBound == nil else { return }
565 688
             chartNow = now
566 689
         }
567 690
     }
568 691
 
569
-    private func chartToggleBar() -> some View {
692
+    private func chartTopToolbar(
693
+        voltageSeries: SeriesData,
694
+        currentSeries: SeriesData
695
+    ) -> some View {
570 696
         let condensedLayout = compactLayout || verticalSizeClass == .compact
571 697
         let sectionSpacing: CGFloat = condensedLayout ? 8 : (isLargeDisplay ? 12 : 10)
572 698
 
573
-        let controlsPanel = HStack(alignment: .center, spacing: sectionSpacing) {
699
+        let seriesPanel = HStack(alignment: .center, spacing: sectionSpacing) {
574 700
             seriesToggleRow(condensedLayout: condensedLayout)
575 701
         }
576 702
         .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 14 : 12))
@@ -584,64 +710,275 @@ struct MeasurementChartView: View {
584 710
                 .stroke(Color.secondary.opacity(0.14), lineWidth: 1)
585 711
         )
586 712
 
713
+        let controlPanel = chartControlsPanel(
714
+            voltageSeries: voltageSeries,
715
+            currentSeries: currentSeries,
716
+            condensedLayout: condensedLayout
717
+        )
718
+
587 719
         return Group {
588 720
             if stackedToolbarLayout {
589
-                controlsPanel
721
+                VStack(alignment: .leading, spacing: 8) {
722
+                    seriesPanel
723
+                    controlPanel
724
+                }
590 725
             } else {
591
-                HStack(alignment: .top, spacing: isLargeDisplay ? 14 : 12) {
592
-                    controlsPanel
726
+                HStack(alignment: .center, spacing: isLargeDisplay ? 14 : 12) {
727
+                    seriesPanel
728
+                    Spacer(minLength: 0)
729
+                    controlPanel
593 730
                 }
594 731
             }
595 732
         }
596 733
         .frame(maxWidth: .infinity, alignment: .leading)
597 734
     }
598 735
 
599
-    private var shouldFloatScaleControlsOverChart: Bool {
600
-        #if os(iOS)
601
-        if availableSize.width > 0, availableSize.height > 0 {
602
-            return availableSize.width > availableSize.height
603
-        }
604
-        return horizontalSizeClass != .compact && verticalSizeClass == .compact
605
-        #else
606
-        return false
607
-        #endif
608
-    }
609
-
610
-    private func scaleControlsPill(
736
+    private func chartControlsPanel(
611 737
         voltageSeries: SeriesData,
612
-        currentSeries: SeriesData
738
+        currentSeries: SeriesData,
739
+        condensedLayout: Bool
613 740
     ) -> some View {
614
-        let condensedLayout = compactLayout || verticalSizeClass == .compact
615
-        let showsCardBackground = !(isIPhone && isPortraitLayout) && !shouldFloatScaleControlsOverChart
616
-        let horizontalPadding: CGFloat = usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 10 : (isLargeDisplay ? 12 : 10))
617
-        let verticalPadding: CGFloat = usesCompactLandscapeOriginControls ? 5 : (condensedLayout ? 8 : (isLargeDisplay ? 10 : 8))
618
-
619
-        return originControlsRow(
741
+        originControlsRow(
620 742
             voltageSeries: voltageSeries,
621 743
             currentSeries: currentSeries,
622 744
             condensedLayout: condensedLayout,
623
-            showsLabel: !shouldFloatScaleControlsOverChart && showsLabeledOriginControls
745
+            showsLabel: showsLabeledOriginControls && !stackedToolbarLayout
624 746
         )
625
-        .padding(.horizontal, horizontalPadding)
626
-        .padding(.vertical, verticalPadding)
747
+        .padding(.horizontal, condensedLayout ? 8 : (isLargeDisplay ? 12 : 10))
748
+        .padding(.vertical, condensedLayout ? 7 : (isLargeDisplay ? 10 : 8))
627 749
         .background(
628
-            Capsule(style: .continuous)
629
-                .fill(showsCardBackground ? Color.primary.opacity(0.08) : Color.clear)
750
+            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
751
+                .fill(Color.primary.opacity(0.045))
630 752
         )
631 753
         .overlay(
632
-            Capsule(style: .continuous)
633
-                .stroke(
634
-                    showsCardBackground ? Color.secondary.opacity(0.18) : Color.clear,
635
-                    lineWidth: 1
636
-                )
754
+            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
755
+                .stroke(Color.secondary.opacity(0.14), lineWidth: 1)
637 756
         )
638 757
     }
639 758
 
759
+    private func chartLegendEntries(
760
+        powerSeries: SeriesData,
761
+        energySeries: SeriesData,
762
+        voltageSeries: SeriesData,
763
+        currentSeries: SeriesData,
764
+        temperatureSeries: SeriesData,
765
+        batteryPercentSeries: SeriesData
766
+    ) -> [SeriesLegendEntry] {
767
+        var entries: [SeriesLegendEntry] = []
768
+
769
+        if displayBatteryPercent {
770
+            entries.append(contentsOf: legendEntry(for: batteryPercentSeries))
771
+        } else if displayPower {
772
+            entries.append(contentsOf: legendEntry(for: powerSeries))
773
+        } else if displayEnergy {
774
+            entries.append(contentsOf: legendEntry(for: energySeries))
775
+        } else {
776
+            if displayVoltage {
777
+                entries.append(contentsOf: legendEntry(for: voltageSeries))
778
+            }
779
+            if displayCurrent {
780
+                entries.append(contentsOf: legendEntry(for: currentSeries))
781
+            }
782
+        }
783
+
784
+        if displayTemperature {
785
+            entries.append(contentsOf: legendEntry(for: temperatureSeries))
786
+        }
787
+
788
+        return entries
789
+    }
790
+
791
+    private func legendEntry(for series: SeriesData) -> [SeriesLegendEntry] {
792
+        let samples = series.samplePoints
793
+        guard
794
+            let minimumValue = samples.map(\.value).min(),
795
+            let maximumValue = samples.map(\.value).max(),
796
+            let lastValue = samples.last?.value
797
+        else {
798
+            return []
799
+        }
800
+
801
+        let averageValue = samples.reduce(0) { $0 + $1.value } / Double(samples.count)
802
+        let values = legendValues(
803
+            for: series.kind,
804
+            minimumValue: minimumValue,
805
+            averageValue: averageValue,
806
+            maximumValue: maximumValue,
807
+            lastValue: lastValue
808
+        )
809
+
810
+        return [
811
+            SeriesLegendEntry(
812
+                id: series.kind,
813
+                name: series.kind.displayName,
814
+                tint: series.kind.tint,
815
+                values: values
816
+            )
817
+        ]
818
+    }
819
+
820
+    private func legendValues(
821
+        for kind: SeriesKind,
822
+        minimumValue: Double,
823
+        averageValue: Double,
824
+        maximumValue: Double,
825
+        lastValue: Double
826
+    ) -> [SeriesLegendValue] {
827
+        switch kind {
828
+        case .energy:
829
+            return [
830
+                SeriesLegendValue(
831
+                    statistic: .total,
832
+                    text: legendValueText(lastValue, for: kind)
833
+                )
834
+            ]
835
+        case .batteryPercent:
836
+            return [
837
+                SeriesLegendValue(
838
+                    statistic: .minimum,
839
+                    text: legendValueText(minimumValue, for: kind)
840
+                ),
841
+                SeriesLegendValue(
842
+                    statistic: .maximum,
843
+                    text: legendValueText(maximumValue, for: kind)
844
+                ),
845
+                SeriesLegendValue(
846
+                    statistic: .last,
847
+                    text: legendValueText(lastValue, for: kind)
848
+                )
849
+            ]
850
+        case .power, .voltage, .current, .temperature:
851
+            return [
852
+                SeriesLegendValue(
853
+                    statistic: .minimum,
854
+                    text: legendValueText(minimumValue, for: kind)
855
+                ),
856
+                SeriesLegendValue(
857
+                    statistic: .average,
858
+                    text: legendValueText(averageValue, for: kind)
859
+                ),
860
+                SeriesLegendValue(
861
+                    statistic: .maximum,
862
+                    text: legendValueText(maximumValue, for: kind)
863
+                ),
864
+                SeriesLegendValue(
865
+                    statistic: .last,
866
+                    text: legendValueText(lastValue, for: kind)
867
+                )
868
+            ]
869
+        }
870
+    }
871
+
872
+    @ViewBuilder
873
+    private func chartLegend(entries: [SeriesLegendEntry]) -> some View {
874
+        if !entries.isEmpty {
875
+            let nameWidth: CGFloat = compactLayout ? 88 : (isLargeDisplay ? 128 : 108)
876
+            let valueWidth: CGFloat = compactLayout ? 78 : (isLargeDisplay ? 108 : 92)
877
+            let statistics = legendStatistics(for: entries)
878
+
879
+            ScrollView(.horizontal, showsIndicators: false) {
880
+                VStack(alignment: .leading, spacing: compactLayout ? 5 : 7) {
881
+                    HStack(spacing: compactLayout ? 8 : 10) {
882
+                        legendHeaderText("Measurement", width: nameWidth, alignment: .leading)
883
+                        ForEach(statistics, id: \.self) { statistic in
884
+                            legendHeaderText(statistic.title, width: valueWidth)
885
+                        }
886
+                    }
887
+
888
+                    ForEach(entries) { entry in
889
+                        HStack(spacing: compactLayout ? 8 : 10) {
890
+                            HStack(spacing: 6) {
891
+                                Circle()
892
+                                    .fill(entry.tint)
893
+                                    .frame(width: compactLayout ? 7 : 8, height: compactLayout ? 7 : 8)
894
+
895
+                                Text(entry.name)
896
+                                    .lineLimit(1)
897
+                                    .minimumScaleFactor(0.82)
898
+                            }
899
+                            .frame(width: nameWidth, alignment: .leading)
900
+
901
+                            ForEach(statistics, id: \.self) { statistic in
902
+                                legendValueText(entry.text(for: statistic) ?? "-", width: valueWidth)
903
+                            }
904
+                        }
905
+                    }
906
+                }
907
+                .padding(.horizontal, compactLayout ? 10 : 12)
908
+                .padding(.vertical, compactLayout ? 8 : 10)
909
+            }
910
+            .background(
911
+                RoundedRectangle(cornerRadius: compactLayout ? 14 : 16, style: .continuous)
912
+                    .fill(Color.primary.opacity(0.045))
913
+            )
914
+            .overlay(
915
+                RoundedRectangle(cornerRadius: compactLayout ? 14 : 16, style: .continuous)
916
+                    .stroke(Color.secondary.opacity(0.14), lineWidth: 1)
917
+            )
918
+        }
919
+    }
920
+
921
+    private func legendStatistics(for entries: [SeriesLegendEntry]) -> [LegendStatistic] {
922
+        LegendStatistic.allCases.filter { statistic in
923
+            entries.contains { $0.text(for: statistic) != nil }
924
+        }
925
+    }
926
+
927
+    private func legendHeaderText(
928
+        _ text: String,
929
+        width: CGFloat,
930
+        alignment: Alignment = .trailing
931
+    ) -> some View {
932
+        Text(text)
933
+            .font((compactLayout ? Font.caption2 : .caption).weight(.semibold))
934
+            .foregroundColor(.secondary)
935
+            .textCase(.uppercase)
936
+            .lineLimit(1)
937
+            .frame(width: width, alignment: alignment)
938
+    }
939
+
940
+    private func legendValueText(
941
+        _ text: String,
942
+        width: CGFloat
943
+    ) -> some View {
944
+        Text(text)
945
+            .font((compactLayout ? Font.caption2 : .caption).weight(.semibold))
946
+            .monospacedDigit()
947
+            .lineLimit(1)
948
+            .minimumScaleFactor(0.78)
949
+            .frame(width: width, alignment: .trailing)
950
+    }
951
+
952
+    private func legendValueText(
953
+        _ value: Double,
954
+        for kind: SeriesKind
955
+    ) -> String {
956
+        let decimalDigits: Int
957
+        switch kind {
958
+        case .power:
959
+            decimalDigits = 2
960
+        case .energy, .voltage, .current:
961
+            decimalDigits = 3
962
+        case .temperature, .batteryPercent:
963
+            decimalDigits = 1
964
+        }
965
+
966
+        let formattedValue = value.format(decimalDigits: decimalDigits)
967
+        let unit = measurementUnit(for: kind)
968
+        guard !unit.isEmpty else { return formattedValue }
969
+
970
+        if kind == .temperature {
971
+            return "\(formattedValue)\(unit)"
972
+        }
973
+        return "\(formattedValue) \(unit)"
974
+    }
975
+
640 976
     private func seriesToggleRow(condensedLayout: Bool) -> some View {
641 977
         HStack(spacing: condensedLayout ? 6 : 8) {
642 978
             seriesToggleButton(title: "Voltage", isOn: displayVoltage, condensedLayout: condensedLayout) {
643 979
                 displayVoltage.toggle()
644 980
                 if displayVoltage {
981
+                    displayBatteryPercent = false
645 982
                     displayPower = false
646 983
                     displayEnergy = false
647 984
                     if displayTemperature && displayCurrent {
@@ -653,6 +990,7 @@ struct MeasurementChartView: View {
653 990
             seriesToggleButton(title: "Current", isOn: displayCurrent, condensedLayout: condensedLayout) {
654 991
                 displayCurrent.toggle()
655 992
                 if displayCurrent {
993
+                    displayBatteryPercent = false
656 994
                     displayPower = false
657 995
                     displayEnergy = false
658 996
                     if displayTemperature && displayVoltage {
@@ -664,6 +1002,7 @@ struct MeasurementChartView: View {
664 1002
             seriesToggleButton(title: "Power", isOn: displayPower, condensedLayout: condensedLayout) {
665 1003
                 displayPower.toggle()
666 1004
                 if displayPower {
1005
+                    displayBatteryPercent = false
667 1006
                     displayEnergy = false
668 1007
                     displayCurrent = false
669 1008
                     displayVoltage = false
@@ -673,12 +1012,25 @@ struct MeasurementChartView: View {
673 1012
             seriesToggleButton(title: "Energy", isOn: displayEnergy, condensedLayout: condensedLayout) {
674 1013
                 displayEnergy.toggle()
675 1014
                 if displayEnergy {
1015
+                    displayBatteryPercent = false
676 1016
                     displayPower = false
677 1017
                     displayCurrent = false
678 1018
                     displayVoltage = false
679 1019
                 }
680 1020
             }
681 1021
 
1022
+            if showsBatteryPercentSeries {
1023
+                seriesToggleButton(title: "Battery", isOn: displayBatteryPercent, condensedLayout: condensedLayout) {
1024
+                    displayBatteryPercent.toggle()
1025
+                    if displayBatteryPercent {
1026
+                        displayPower = false
1027
+                        displayEnergy = false
1028
+                        displayCurrent = false
1029
+                        displayVoltage = false
1030
+                    }
1031
+                }
1032
+            }
1033
+
682 1034
             if showsTemperatureSeries {
683 1035
                 seriesToggleButton(title: "Temp", isOn: displayTemperature, condensedLayout: condensedLayout) {
684 1036
                     displayTemperature.toggle()
@@ -939,9 +1291,18 @@ struct MeasurementChartView: View {
939 1291
         powerSeries: SeriesData,
940 1292
         energySeries: SeriesData,
941 1293
         voltageSeries: SeriesData,
942
-        currentSeries: SeriesData
1294
+        currentSeries: SeriesData,
1295
+        batteryPercentSeries: SeriesData
943 1296
     ) -> some View {
944
-        if displayPower {
1297
+        if displayBatteryPercent {
1298
+            yAxisLabelsView(
1299
+                height: height,
1300
+                context: batteryPercentSeries.context,
1301
+                seriesKind: .batteryPercent,
1302
+                measurementUnit: batteryPercentSeries.kind.unit,
1303
+                tint: batteryPercentSeries.kind.tint
1304
+            )
1305
+        } else if displayPower {
945 1306
             yAxisLabelsView(
946 1307
                 height: height,
947 1308
                 context: powerSeries.context,
@@ -982,9 +1343,14 @@ struct MeasurementChartView: View {
982 1343
         energySeries: SeriesData,
983 1344
         voltageSeries: SeriesData,
984 1345
         currentSeries: SeriesData,
985
-        temperatureSeries: SeriesData
1346
+        temperatureSeries: SeriesData,
1347
+        batteryPercentSeries: SeriesData
986 1348
     ) -> some View {
987
-        if self.displayPower {
1349
+        if self.displayBatteryPercent {
1350
+            TimeSeriesChart(points: batteryPercentSeries.points, context: batteryPercentSeries.context, strokeColor: batteryPercentSeries.kind.tint)
1351
+                .opacity(0.82)
1352
+            batteryCheckpointMarkers(context: batteryPercentSeries.context)
1353
+        } else if self.displayPower {
988 1354
             TimeSeriesChart(points: powerSeries.points, context: powerSeries.context, strokeColor: powerSeries.kind.tint)
989 1355
                 .opacity(0.72)
990 1356
         } else if self.displayEnergy {
@@ -1007,6 +1373,57 @@ struct MeasurementChartView: View {
1007 1373
         }
1008 1374
     }
1009 1375
 
1376
+    private func batteryCheckpointMarkers(context: ChartContext) -> some View {
1377
+        GeometryReader { geometry in
1378
+            ForEach(visibleBatteryCheckpoints(context: context)) { checkpoint in
1379
+                let normalizedPoint = context.placeInRect(
1380
+                    point: CGPoint(
1381
+                        x: checkpoint.timestamp.timeIntervalSince1970,
1382
+                        y: checkpoint.batteryPercent
1383
+                    )
1384
+                )
1385
+                let location = CGPoint(
1386
+                    x: normalizedPoint.x * geometry.size.width,
1387
+                    y: normalizedPoint.y * geometry.size.height
1388
+                )
1389
+
1390
+                Circle()
1391
+                    .fill(Color(.systemBackground))
1392
+                    .frame(width: 10, height: 10)
1393
+                    .overlay(
1394
+                        Circle()
1395
+                            .stroke(Color.mint, lineWidth: 2)
1396
+                    )
1397
+                    .shadow(color: Color.black.opacity(0.12), radius: 2, x: 0, y: 1)
1398
+                    .position(location)
1399
+            }
1400
+        }
1401
+        .accessibilityHidden(true)
1402
+    }
1403
+
1404
+    private func visibleBatteryCheckpoints(context: ChartContext) -> [ChargeCheckpointSummary] {
1405
+        guard context.isValid else { return [] }
1406
+
1407
+        return batteryCheckpoints
1408
+            .filter { checkpoint in
1409
+                checkpoint.batteryPercent.isFinite &&
1410
+                checkpoint.batteryPercent >= 0 &&
1411
+                checkpoint.batteryPercent <= 100
1412
+            }
1413
+            .filter { checkpoint in
1414
+                let normalizedPoint = context.placeInRect(
1415
+                    point: CGPoint(
1416
+                        x: checkpoint.timestamp.timeIntervalSince1970,
1417
+                        y: checkpoint.batteryPercent
1418
+                    )
1419
+                )
1420
+                return normalizedPoint.x >= 0 &&
1421
+                    normalizedPoint.x <= 1 &&
1422
+                    normalizedPoint.y >= 0 &&
1423
+                    normalizedPoint.y <= 1
1424
+            }
1425
+    }
1426
+
1010 1427
     @ViewBuilder
1011 1428
     private func secondaryAxisView(
1012 1429
         height: CGFloat,
@@ -1014,7 +1431,8 @@ struct MeasurementChartView: View {
1014 1431
         energySeries: SeriesData,
1015 1432
         voltageSeries: SeriesData,
1016 1433
         currentSeries: SeriesData,
1017
-        temperatureSeries: SeriesData
1434
+        temperatureSeries: SeriesData,
1435
+        batteryPercentSeries: SeriesData
1018 1436
     ) -> some View {
1019 1437
         if displayTemperature {
1020 1438
             yAxisLabelsView(
@@ -1038,7 +1456,8 @@ struct MeasurementChartView: View {
1038 1456
                 powerSeries: powerSeries,
1039 1457
                 energySeries: energySeries,
1040 1458
                 voltageSeries: voltageSeries,
1041
-                currentSeries: currentSeries
1459
+                currentSeries: currentSeries,
1460
+                batteryPercentSeries: batteryPercentSeries
1042 1461
             )
1043 1462
         }
1044 1463
     }
@@ -1047,8 +1466,12 @@ struct MeasurementChartView: View {
1047 1466
         powerSeries: SeriesData,
1048 1467
         energySeries: SeriesData,
1049 1468
         voltageSeries: SeriesData,
1050
-        currentSeries: SeriesData
1469
+        currentSeries: SeriesData,
1470
+        batteryPercentSeries: SeriesData
1051 1471
     ) -> SeriesData? {
1472
+        if displayBatteryPercent {
1473
+            return batteryPercentSeries
1474
+        }
1052 1475
         if displayPower {
1053 1476
             return powerSeries
1054 1477
         }
@@ -1070,19 +1493,34 @@ struct MeasurementChartView: View {
1070 1493
         minimumYSpan: Double,
1071 1494
         visibleTimeRange: ClosedRange<Date>? = nil
1072 1495
     ) -> SeriesData {
1073
-        let rawPoints = filteredPoints(
1074
-            measurement,
1496
+        series(
1497
+            for: filteredPoints(
1498
+                measurement,
1499
+                visibleTimeRange: visibleTimeRange
1500
+            ),
1501
+            kind: kind,
1502
+            minimumYSpan: minimumYSpan,
1075 1503
             visibleTimeRange: visibleTimeRange
1076 1504
         )
1505
+    }
1506
+
1507
+    private func series(
1508
+        for rawPoints: [Measurements.Measurement.Point],
1509
+        kind: SeriesKind,
1510
+        minimumYSpan: Double,
1511
+        visibleTimeRange: ClosedRange<Date>? = nil
1512
+    ) -> SeriesData {
1077 1513
         let normalizedRawPoints = normalizedPoints(rawPoints, for: kind)
1078 1514
         let points = smoothedPoints(from: normalizedRawPoints)
1079 1515
         let samplePoints = points.filter { $0.isSample }
1080 1516
         let context = ChartContext()
1081 1517
 
1082
-        let autoBounds = automaticYBounds(
1083
-            for: samplePoints,
1084
-            minimumYSpan: minimumYSpan
1085
-        )
1518
+        let autoBounds = kind == .batteryPercent
1519
+            ? (lowerBound: 0.0, upperBound: 100.0)
1520
+            : automaticYBounds(
1521
+                for: samplePoints,
1522
+                minimumYSpan: minimumYSpan
1523
+            )
1086 1524
         let xBounds = xBounds(
1087 1525
             for: samplePoints,
1088 1526
             visibleTimeRange: visibleTimeRange
@@ -1236,6 +1674,8 @@ struct MeasurementChartView: View {
1236 1674
             return measurements.current
1237 1675
         case .temperature:
1238 1676
             return measurements.temperature
1677
+        case .batteryPercent:
1678
+            return measurements.batteryPercent
1239 1679
         }
1240 1680
     }
1241 1681
 
@@ -1251,11 +1691,13 @@ struct MeasurementChartView: View {
1251 1691
             return minimumCurrentSpan
1252 1692
         case .temperature:
1253 1693
             return minimumTemperatureSpan
1694
+        case .batteryPercent:
1695
+            return minimumBatteryPercentSpan
1254 1696
         }
1255 1697
     }
1256 1698
 
1257 1699
     private var supportsSharedOrigin: Bool {
1258
-        displayVoltage && displayCurrent && !displayPower && !displayEnergy
1700
+        displayVoltage && displayCurrent && !displayPower && !displayEnergy && !displayBatteryPercent
1259 1701
     }
1260 1702
 
1261 1703
     private var minimumSharedScaleSpan: Double {
@@ -1275,6 +1717,10 @@ struct MeasurementChartView: View {
1275 1717
             return pinOrigin && energyAxisOrigin == 0
1276 1718
         }
1277 1719
 
1720
+        if displayBatteryPercent {
1721
+            return pinOrigin && batteryPercentAxisOrigin == 0
1722
+        }
1723
+
1278 1724
         let visibleOrigins = [
1279 1725
             displayVoltage ? voltageAxisOrigin : nil,
1280 1726
             displayCurrent ? currentAxisOrigin : nil
@@ -1347,6 +1793,9 @@ struct MeasurementChartView: View {
1347 1793
             if displayTemperature {
1348 1794
                 temperatureAxisOrigin = 0
1349 1795
             }
1796
+            if displayBatteryPercent {
1797
+                batteryPercentAxisOrigin = 0
1798
+            }
1350 1799
         }
1351 1800
 
1352 1801
         pinOrigin = true
@@ -1361,6 +1810,7 @@ struct MeasurementChartView: View {
1361 1810
         voltageAxisOrigin = voltageSeries.autoLowerBound
1362 1811
         currentAxisOrigin = currentSeries.autoLowerBound
1363 1812
         temperatureAxisOrigin = displayedLowerBoundForSeries(.temperature)
1813
+        batteryPercentAxisOrigin = displayedLowerBoundForSeries(.batteryPercent)
1364 1814
         sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
1365 1815
         sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
1366 1816
         ensureSharedScaleSpan()
@@ -1426,6 +1876,8 @@ struct MeasurementChartView: View {
1426 1876
                     ),
1427 1877
                     minimumYSpan: minimumTemperatureSpan
1428 1878
                 ).lowerBound
1879
+        case .batteryPercent:
1880
+            return pinOrigin ? batteryPercentAxisOrigin : 0
1429 1881
         }
1430 1882
     }
1431 1883
 
@@ -1514,7 +1966,10 @@ struct MeasurementChartView: View {
1514 1966
             filteredSamplePoints(measurements.energy),
1515 1967
             filteredSamplePoints(measurements.voltage),
1516 1968
             filteredSamplePoints(measurements.current),
1517
-            filteredSamplePoints(measurements.temperature)
1969
+            filteredSamplePoints(measurements.temperature),
1970
+            batteryPercentPoints.isEmpty
1971
+                ? filteredSamplePoints(measurements.batteryPercent)
1972
+                : batteryPercentPoints.filter { $0.isSample }
1518 1973
         ]
1519 1974
 
1520 1975
         return candidates.first(where: { !$0.isEmpty }) ?? []
@@ -1661,6 +2116,8 @@ struct MeasurementChartView: View {
1661 2116
             return currentAxisOrigin
1662 2117
         case .temperature:
1663 2118
             return temperatureAxisOrigin
2119
+        case .batteryPercent:
2120
+            return batteryPercentAxisOrigin
1664 2121
         }
1665 2122
     }
1666 2123
 
@@ -1679,7 +2136,7 @@ struct MeasurementChartView: View {
1679 2136
             return max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
1680 2137
         }
1681 2138
 
1682
-        if kind == .temperature {
2139
+        if kind == .temperature || kind == .batteryPercent {
1683 2140
             return autoUpperBound
1684 2141
         }
1685 2142
 
@@ -1711,6 +2168,8 @@ struct MeasurementChartView: View {
1711 2168
                 currentAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .current))
1712 2169
             case .temperature:
1713 2170
                 temperatureAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .temperature))
2171
+            case .batteryPercent:
2172
+                batteryPercentAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .batteryPercent))
1714 2173
             }
1715 2174
         }
1716 2175
 
@@ -1737,6 +2196,8 @@ struct MeasurementChartView: View {
1737 2196
                 currentAxisOrigin = 0
1738 2197
             case .temperature:
1739 2198
                 temperatureAxisOrigin = 0
2199
+            case .batteryPercent:
2200
+                batteryPercentAxisOrigin = 0
1740 2201
             }
1741 2202
         }
1742 2203
 
@@ -1795,6 +2256,13 @@ struct MeasurementChartView: View {
1795 2256
                     visibleTimeRange: visibleTimeRange
1796 2257
                 ).map(\.value).min() ?? 0
1797 2258
             )
2259
+        case .batteryPercent:
2260
+            return snappedOriginValue(
2261
+                filteredSamplePoints(
2262
+                    measurements.batteryPercent,
2263
+                    visibleTimeRange: visibleTimeRange
2264
+                ).map(\.value).min() ?? 0
2265
+            )
1798 2266
         }
1799 2267
     }
1800 2268
 
@@ -2080,6 +2548,9 @@ private struct TimeRangeSelectorView: View {
2080 2548
     @Binding var presentTrackingMode: PresentTrackingMode
2081 2549
     @State private var dragState: DragState?
2082 2550
     @State private var showResetConfirmation: Bool = false
2551
+    @State private var isShowingCSVExporter: Bool = false
2552
+    @State private var exportFileName: String = "charge-session"
2553
+    @State private var exportDocument = MeasurementChartCSVDocument(content: "")
2083 2554
 
2084 2555
     private var totalSpan: TimeInterval {
2085 2556
         availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound)
@@ -2102,7 +2573,8 @@ private struct TimeRangeSelectorView: View {
2102 2573
         let trackHeight = Self.trackHeight(compactLayout: compactLayout)
2103 2574
         let axisLabelsHeight: CGFloat = compactLayout ? 18 : 20
2104 2575
         let spacing: CGFloat = compactLayout ? 6 : 8
2105
-        return rowHeight + spacing + rowHeight + spacing + trackHeight + spacing + axisLabelsHeight
2576
+        // Single row of controls instead of two
2577
+        return rowHeight + spacing + trackHeight + spacing + axisLabelsHeight
2106 2578
     }
2107 2579
 
2108 2580
     private var cornerRadius: CGFloat {
@@ -2117,8 +2589,9 @@ private struct TimeRangeSelectorView: View {
2117 2589
         let coversFullRange = selectionCoversFullRange(currentRange)
2118 2590
 
2119 2591
         VStack(alignment: .leading, spacing: compactLayout ? 6 : 8) {
2120
-            if !coversFullRange || isPinnedToPresent {
2121
-                HStack(spacing: 8) {
2592
+            HStack(spacing: 8) {
2593
+                // Alignment controls
2594
+                if !coversFullRange || isPinnedToPresent {
2122 2595
                     alignmentButton(
2123 2596
                         systemName: "arrow.left.to.line.compact",
2124 2597
                         isActive: currentRange.lowerBound == availableTimeRange.lowerBound && !isPinnedToPresent,
@@ -2133,19 +2606,28 @@ private struct TimeRangeSelectorView: View {
2133 2606
                         accessibilityLabel: "Align selection to present"
2134 2607
                     )
2135 2608
 
2136
-                    Spacer(minLength: 0)
2137
-
2138 2609
                     if isPinnedToPresent {
2139 2610
                         trackingModeToggleButton()
2140 2611
                     }
2141 2612
                 }
2142
-            }
2143 2613
 
2144
-            HStack(spacing: 8) {
2614
+                Spacer(minLength: 0)
2615
+
2616
+                if let exportAction = configuration.exportAction {
2617
+                    iconButton(
2618
+                        systemName: exportAction.systemName,
2619
+                        tone: exportAction.tone,
2620
+                        action: {
2621
+                            beginCSVExport(exportAction)
2622
+                        }
2623
+                    )
2624
+                    .help(exportAction.title)
2625
+                    .accessibilityLabel(exportAction.title)
2626
+                }
2627
+
2628
+                // Trim/Save actions
2145 2629
                 if !coversFullRange {
2146
-                    actionButton(
2147
-                        title: configuration.keepAction.title,
2148
-                        shortTitle: configuration.keepAction.shortTitle,
2630
+                    iconButton(
2149 2631
                         systemName: configuration.keepAction.systemName,
2150 2632
                         tone: configuration.keepAction.tone,
2151 2633
                         action: {
@@ -2153,11 +2635,10 @@ private struct TimeRangeSelectorView: View {
2153 2635
                             resetSelectionState()
2154 2636
                         }
2155 2637
                     )
2638
+                    .help(configuration.keepAction.title)
2156 2639
 
2157 2640
                     if let removeAction = configuration.removeAction {
2158
-                        actionButton(
2159
-                            title: removeAction.title,
2160
-                            shortTitle: removeAction.shortTitle,
2641
+                        iconButton(
2161 2642
                             systemName: removeAction.systemName,
2162 2643
                             tone: removeAction.tone,
2163 2644
                             action: {
@@ -2165,27 +2646,26 @@ private struct TimeRangeSelectorView: View {
2165 2646
                                 resetSelectionState()
2166 2647
                             }
2167 2648
                         )
2649
+                        .help(removeAction.title)
2168 2650
                     }
2169
-                }
2170 2651
 
2171
-                Spacer(minLength: 0)
2172
-
2173
-                actionButton(
2174
-                    title: configuration.resetAction.title,
2175
-                    shortTitle: configuration.resetAction.shortTitle,
2176
-                    systemName: configuration.resetAction.systemName,
2177
-                    tone: configuration.resetAction.tone,
2178
-                    action: {
2179
-                        showResetConfirmation = true
2652
+                    // Reset action (only show when there's a trim to reset)
2653
+                    iconButton(
2654
+                        systemName: configuration.resetAction.systemName,
2655
+                        tone: configuration.resetAction.tone,
2656
+                        action: {
2657
+                            showResetConfirmation = true
2658
+                        }
2659
+                    )
2660
+                    .help(configuration.resetAction.title)
2661
+                    .confirmationDialog(configuration.resetAction.confirmationTitle, isPresented: $showResetConfirmation, titleVisibility: .visible) {
2662
+                        Button(configuration.resetAction.confirmationButtonTitle, role: .destructive) {
2663
+                            configuration.resetAction.handler()
2664
+                            resetSelectionState()
2665
+                        }
2666
+                        Button("Cancel", role: .cancel) {}
2180 2667
                     }
2181
-                )
2182
-            }
2183
-            .confirmationDialog(configuration.resetAction.confirmationTitle, isPresented: $showResetConfirmation, titleVisibility: .visible) {
2184
-                Button(configuration.resetAction.confirmationButtonTitle, role: .destructive) {
2185
-                    configuration.resetAction.handler()
2186
-                    resetSelectionState()
2187 2668
                 }
2188
-                Button("Cancel", role: .cancel) {}
2189 2669
             }
2190 2670
 
2191 2671
             GeometryReader { geometry in
@@ -2264,6 +2744,12 @@ private struct TimeRangeSelectorView: View {
2264 2744
 
2265 2745
             xAxisLabelsView
2266 2746
         }
2747
+        .fileExporter(
2748
+            isPresented: $isShowingCSVExporter,
2749
+            document: exportDocument,
2750
+            contentType: .commaSeparatedText,
2751
+            defaultFilename: exportFileName
2752
+        ) { _ in }
2267 2753
     }
2268 2754
 
2269 2755
     private func handleView(height: CGFloat) -> some View {
@@ -2321,6 +2807,13 @@ private struct TimeRangeSelectorView: View {
2321 2807
         .accessibilityHint("Toggles how the interval follows the present")
2322 2808
     }
2323 2809
 
2810
+    private func beginCSVExport(_ action: MeasurementChartExportAction) {
2811
+        let exportRange = currentRange
2812
+        exportFileName = action.fileName(exportRange)
2813
+        exportDocument = MeasurementChartCSVDocument(content: action.content(exportRange))
2814
+        isShowingCSVExporter = true
2815
+    }
2816
+
2324 2817
     private func actionButton(
2325 2818
         title: String,
2326 2819
         shortTitle: String? = nil,
@@ -2356,6 +2849,37 @@ private struct TimeRangeSelectorView: View {
2356 2849
         )
2357 2850
     }
2358 2851
 
2852
+    private func iconButton(
2853
+        systemName: String,
2854
+        tone: MeasurementChartSelectorActionTone,
2855
+        action: @escaping () -> Void
2856
+    ) -> some View {
2857
+        let foregroundColor: Color = {
2858
+            switch tone {
2859
+            case .reversible, .destructive:
2860
+                return toneColor(for: tone)
2861
+            case .destructiveProminent:
2862
+                return .white
2863
+            }
2864
+        }()
2865
+
2866
+        return Button(action: action) {
2867
+            Image(systemName: systemName)
2868
+                .font(.subheadline.weight(.semibold))
2869
+                .frame(width: symbolButtonSize, height: symbolButtonSize)
2870
+        }
2871
+        .buttonStyle(.plain)
2872
+        .foregroundColor(foregroundColor)
2873
+        .background(
2874
+            RoundedRectangle(cornerRadius: max(symbolButtonSize / 3, 6), style: .continuous)
2875
+                .fill(actionButtonBackground(for: tone))
2876
+        )
2877
+        .overlay(
2878
+            RoundedRectangle(cornerRadius: max(symbolButtonSize / 3, 6), style: .continuous)
2879
+                .stroke(actionButtonBorder(for: tone), lineWidth: 1)
2880
+        )
2881
+    }
2882
+
2359 2883
     private func toneColor(for tone: MeasurementChartSelectorActionTone) -> Color {
2360 2884
         switch tone {
2361 2885
         case .reversible:
@@ -2392,7 +2916,7 @@ private struct TimeRangeSelectorView: View {
2392 2916
         case .keepDuration:
2393 2917
             return "arrow.left.and.right"
2394 2918
         case .keepStartTimestamp:
2395
-            return "arrow.left.to.line.compact"
2919
+            return "arrow.right"
2396 2920
         }
2397 2921
     }
2398 2922
 
@@ -2772,6 +3296,8 @@ private struct TimeRangeSelectorView: View {
2772 3296
                 ForEach(Array(labels.enumerated()), id: \.offset) { item in
2773 3297
                     let labelIndex = item.offset + 1
2774 3298
                     let centerX = xPosition(for: labelIndex, totalLabels: labelCount, width: geometry.size.width)
3299
+                    let halfWidth = labelWidth / 2
3300
+                    let clampedX = min(max(centerX, halfWidth), geometry.size.width - halfWidth)
2775 3301
 
2776 3302
                     Text(item.element)
2777 3303
                         .font(axisLabelFont)
@@ -2780,7 +3306,7 @@ private struct TimeRangeSelectorView: View {
2780 3306
                         .minimumScaleFactor(0.74)
2781 3307
                         .frame(width: labelWidth)
2782 3308
                         .position(
2783
-                            x: centerX,
3309
+                            x: clampedX,
2784 3310
                             y: geometry.size.height * 0.66
2785 3311
                         )
2786 3312
                 }
+26 -2
USB Meter/Views/Meter/Components/MeterInfoCardView.swift
@@ -9,6 +9,8 @@ struct MeterInfoCardView<Content: View, TrailingActions: View>: View {
9 9
     let title: String
10 10
     let infoMessage: String?
11 11
     let tint: Color
12
+    let isCollapsible: Bool
13
+    @State private var isExpanded: Bool
12 14
     @ViewBuilder var trailingActions: TrailingActions
13 15
     @ViewBuilder var content: Content
14 16
 
@@ -16,18 +18,22 @@ struct MeterInfoCardView<Content: View, TrailingActions: View>: View {
16 18
         title: String,
17 19
         infoMessage: String? = nil,
18 20
         tint: Color,
21
+        isCollapsible: Bool = false,
22
+        initiallyExpanded: Bool = true,
19 23
         @ViewBuilder trailingActions: () -> TrailingActions = { EmptyView() },
20 24
         @ViewBuilder content: () -> Content
21 25
     ) {
22 26
         self.title = title
23 27
         self.infoMessage = infoMessage
24 28
         self.tint = tint
29
+        self.isCollapsible = isCollapsible
30
+        self._isExpanded = State(initialValue: initiallyExpanded)
25 31
         self.trailingActions = trailingActions()
26 32
         self.content = content()
27 33
     }
28 34
 
29 35
     var body: some View {
30
-        VStack(alignment: .leading, spacing: 12) {
36
+        VStack(alignment: .leading, spacing: isExpanded ? 12 : 0) {
31 37
             HStack(spacing: 8) {
32 38
                 Text(title)
33 39
                     .font(.headline)
@@ -36,8 +42,26 @@ struct MeterInfoCardView<Content: View, TrailingActions: View>: View {
36 42
                 }
37 43
                 Spacer(minLength: 0)
38 44
                 trailingActions
45
+                if isCollapsible {
46
+                    Image(systemName: "chevron.up")
47
+                        .font(.caption.weight(.semibold))
48
+                        .foregroundColor(.secondary)
49
+                        .rotationEffect(.degrees(isExpanded ? 0 : -180))
50
+                        .animation(.easeInOut(duration: 0.2), value: isExpanded)
51
+                }
52
+            }
53
+            .contentShape(Rectangle())
54
+            .onTapGesture {
55
+                guard isCollapsible else { return }
56
+                withAnimation(.easeInOut(duration: 0.25)) {
57
+                    isExpanded.toggle()
58
+                }
59
+            }
60
+
61
+            if isExpanded {
62
+                content
63
+                    .transition(.opacity.combined(with: .move(edge: .top)))
39 64
             }
40
-            content
41 65
         }
42 66
         .frame(maxWidth: .infinity, alignment: .leading)
43 67
         .padding(18)
+17 -161
USB Meter/Views/Meter/MeterView.swift
@@ -106,13 +106,7 @@ struct MeterView: View {
106 106
     private static let isPhone: Bool = false
107 107
     #endif
108 108
 
109
-    // True only on Mac iPad App (Designed for iPad), false on Catalyst
110
-    private static let isTrueMacApp: Bool = ProcessInfo.processInfo.isiOSAppOnMac && !ProcessInfo.processInfo.isMacCatalystApp
111
-    
112 109
     @State private var selectedMeterTab: MeterTab = .home
113
-    @State private var navBarTitle: String = "Meter"
114
-    @State private var navBarShowRSSI: Bool = false
115
-    @State private var navBarRSSI: Int = 0
116 110
     @State private var landscapeTabBarHeight: CGFloat = 0
117 111
 
118 112
     // Offline mode state
@@ -151,10 +145,6 @@ struct MeterView: View {
151 145
             )
152 146
 
153 147
             VStack(spacing: 0) {
154
-                // Use custom header only on true Mac iPad App (Designed for iPad on Mac)
155
-                if Self.isTrueMacApp {
156
-                    macNavigationHeader
157
-                }
158 148
                 Group {
159 149
                     if landscape {
160 150
                         landscapeDeck(
@@ -173,34 +163,23 @@ struct MeterView: View {
173 163
                 }
174 164
                 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
175 165
             }
176
-            #if !targetEnvironment(macCatalyst)
177
-            .navigationBarHidden(Self.isTrueMacApp && landscape)
178
-            #else
179
-            .navigationBarHidden(landscape)
180
-            #endif
181 166
         }
182 167
         .background(meterBackground)
183
-        .modifier(IOSOnlyNavBar(
184
-            apply: !Self.isTrueMacApp,
185
-            title: navBarTitle,
186
-            showRSSI: navBarShowRSSI,
187
-            rssi: navBarRSSI,
188
-            meter: meter
189
-        ))
190
-        .onAppear {
191
-            navBarTitle = meter.name.isEmpty ? "Meter" : meter.name
192
-            navBarShowRSSI = meter.operationalState > .notPresent
193
-            navBarRSSI = meter.btSerial.averageRSSI
194
-        }
195
-        .onChange(of: meter.name) { name in
196
-            navBarTitle = name.isEmpty ? "Meter" : name
197
-        }
198
-        .onChange(of: meter.operationalState) { state in
199
-            navBarShowRSSI = state > .notPresent
200
-        }
201
-        .onChange(of: meter.btSerial.averageRSSI) { newRSSI in
202
-            if abs(newRSSI - navBarRSSI) >= 5 {
203
-                navBarRSSI = newRSSI
168
+        .navigationTitle(meter.name.isEmpty ? "Meter" : meter.name)
169
+        .navigationBarTitleDisplayMode(.inline)
170
+        .toolbar {
171
+            ToolbarItemGroup(placement: .navigationBarTrailing) {
172
+                MeterConnectionToolbarButton(
173
+                    operationalState: meter.operationalState,
174
+                    showsTitle: false,
175
+                    connectAction: { meter.connect() },
176
+                    disconnectAction: { meter.disconnect() }
177
+                )
178
+                .font(.body.weight(.semibold))
179
+                if meter.operationalState > .notPresent {
180
+                    RSSIView(RSSI: meter.btSerial.averageRSSI)
181
+                        .frame(width: 18, height: 18)
182
+                }
204 183
             }
205 184
         }
206 185
         .onChange(of: selectedMeterTab) { newTab in
@@ -208,55 +187,6 @@ struct MeterView: View {
208 187
         }
209 188
     }
210 189
 
211
-    // MARK: - Custom navigation header for Designed-for-iPad on Mac
212
-
213
-    private var macNavigationHeader: some View {
214
-        HStack(spacing: 12) {
215
-            Button {
216
-                dismiss()
217
-            } label: {
218
-                HStack(spacing: 4) {
219
-                    Image(systemName: "chevron.left")
220
-                        .font(.body.weight(.semibold))
221
-                    Text("USB Meters")
222
-                }
223
-                .foregroundColor(.accentColor)
224
-            }
225
-            .buttonStyle(.plain)
226
-
227
-            Text(meter.name.isEmpty ? "Meter" : meter.name)
228
-                .font(.headline)
229
-                .lineLimit(1)
230
-
231
-            Spacer()
232
-
233
-            MeterConnectionToolbarButton(
234
-                operationalState: meter.operationalState,
235
-                showsTitle: true,
236
-                connectAction: { meter.connect() },
237
-                disconnectAction: { meter.disconnect() }
238
-            )
239
-
240
-            if meter.operationalState > .notPresent {
241
-                RSSIView(RSSI: meter.btSerial.averageRSSI)
242
-                    .frame(width: 18, height: 18)
243
-            }
244
-
245
-        }
246
-        .padding(.horizontal, 16)
247
-        .padding(.vertical, 10)
248
-        .background(
249
-            Rectangle()
250
-                .fill(.ultraThinMaterial)
251
-                .ignoresSafeArea(edges: .top)
252
-        )
253
-        .overlay(alignment: .bottom) {
254
-            Rectangle()
255
-                .fill(Color.secondary.opacity(0.12))
256
-                .frame(height: 1)
257
-        }
258
-    }
259
-
260 190
     private func portraitContent(
261 191
         size: CGSize,
262 192
         tabBarStyle: TabBarStyle,
@@ -684,9 +614,6 @@ struct MeterView: View {
684 614
     @ViewBuilder
685 615
     private func offlineBody(summary: AppData.MeterSummary) -> some View {
686 616
         VStack(spacing: 0) {
687
-            if Self.isTrueMacApp {
688
-                offlineMacHeader(name: summary.displayName)
689
-            }
690 617
             offlineTabBar(tint: summary.tint)
691 618
             offlineTabContent(summary: summary)
692 619
                 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
@@ -695,12 +622,8 @@ struct MeterView: View {
695 622
                 .animation(.easeInOut(duration: 0.22), value: selectedOfflineTab)
696 623
         }
697 624
         .background(offlineBackground(tint: summary.tint))
698
-        #if !targetEnvironment(macCatalyst)
699
-        .navigationBarHidden(Self.isTrueMacApp)
700
-        #else
701
-        .navigationBarHidden(false)
702
-        #endif
703
-        .navigationBarTitle(summary.displayName, displayMode: .inline)
625
+        .navigationTitle(summary.displayName)
626
+        .navigationBarTitleDisplayMode(.inline)
704 627
         .onAppear {
705 628
             offlineName = summary.displayName
706 629
             offlineTemperatureUnit = appData.temperatureUnitPreference(for: summary.macAddress)
@@ -873,34 +796,6 @@ struct MeterView: View {
873 796
         .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24)
874 797
     }
875 798
 
876
-    private func offlineMacHeader(name: String) -> some View {
877
-        HStack(spacing: 12) {
878
-            Button { dismiss() } label: {
879
-                HStack(spacing: 4) {
880
-                    Image(systemName: "chevron.left")
881
-                        .font(.body.weight(.semibold))
882
-                    Text("USB Meters")
883
-                }
884
-                .foregroundColor(.accentColor)
885
-            }
886
-            .buttonStyle(.plain)
887
-            Text(name).font(.headline).lineLimit(1)
888
-            Spacer()
889
-        }
890
-        .padding(.horizontal, 16)
891
-        .padding(.vertical, 10)
892
-        .background(
893
-            Rectangle()
894
-                .fill(.ultraThinMaterial)
895
-                .ignoresSafeArea(edges: .top)
896
-        )
897
-        .overlay(alignment: .bottom) {
898
-            Rectangle()
899
-                .fill(Color.secondary.opacity(0.12))
900
-                .frame(height: 1)
901
-        }
902
-    }
903
-
904 799
     private func offlineStatusHeader(summary: AppData.MeterSummary) -> some View {
905 800
         HStack(spacing: 12) {
906 801
             Image(systemName: "sensor.tag.radiowaves.forward.fill")
@@ -954,42 +849,3 @@ private struct MeterTabBarHeightPreferenceKey: PreferenceKey {
954 849
     }
955 850
 }
956 851
 
957
-// MARK: - Conditional navigation bar modifier (skipped on Designed-for-iPad / Mac)
958
-
959
-private struct IOSOnlyNavBar: ViewModifier {
960
-    let apply: Bool
961
-    let title: String
962
-    let showRSSI: Bool
963
-    let rssi: Int
964
-    let meter: Meter
965
-
966
-    @ViewBuilder
967
-    func body(content: Content) -> some View {
968
-        if apply {
969
-            content
970
-                .navigationBarTitle(title, displayMode: .inline)
971
-                .toolbar {
972
-                    ToolbarItemGroup(placement: .navigationBarTrailing) {
973
-                        MeterConnectionToolbarButton(
974
-                            operationalState: meter.operationalState,
975
-                            showsTitle: false,
976
-                            connectAction: { meter.connect() },
977
-                            disconnectAction: { meter.disconnect() }
978
-                        )
979
-                        .font(.body.weight(.semibold))
980
-                        if showRSSI {
981
-                            RSSIView(RSSI: rssi)
982
-                                .frame(width: 18, height: 18)
983
-                        }
984
-                    }
985
-                }
986
-                #if targetEnvironment(macCatalyst)
987
-                .toolbar {
988
-                    ToolbarItemGroup(placement: .primaryAction) {}
989
-                }
990
-                #endif
991
-        } else {
992
-            content
993
-        }
994
-    }
995
-}
+479 -44
USB Meter/Views/Meter/Tabs/ChargeRecord/MeterChargeRecordTabView.swift
@@ -35,6 +35,7 @@ struct MeterChargeRecordContentView: View {
35 35
     private enum ActiveMode: Hashable {
36 36
         case chargeSession
37 37
         case standbyPower
38
+        case consumptionMonitor
38 39
     }
39 40
 
40 41
     private enum SessionStartRequirement: Identifiable {
@@ -80,6 +81,12 @@ struct MeterChargeRecordContentView: View {
80 81
     @State private var initialCheckpoint = ""
81 82
     @State private var showsMeterTotalsInfo = false
82 83
     @State private var activeMode: ActiveMode = .chargeSession
84
+    @State private var draftChargedDeviceID: UUID?
85
+    @State private var draftChargedPowerbankID: UUID?
86
+    @State private var draftChargerID: UUID?
87
+    @State private var draftSourcePowerbankID: UUID?
88
+    @State private var draftConsumptionDeviceID: UUID?
89
+    @State private var discardConsumptionConfirmation = false
83 90
 
84 91
     var body: some View {
85 92
         Group {
@@ -90,6 +97,8 @@ struct MeterChargeRecordContentView: View {
90 97
                     monitoringMeter: usbMeter,
91 98
                     presentation: .embedded
92 99
                 )
100
+            } else if activeMode == .consumptionMonitor, let session = activeConsumptionSession {
101
+                consumptionSessionActiveView(session)
93 102
             } else {
94 103
                 ScrollView {
95 104
                     VStack(spacing: 14) {
@@ -102,6 +111,8 @@ struct MeterChargeRecordContentView: View {
102 111
                             chargeSessionSetupCard
103 112
                         case .standbyPower:
104 113
                             standbyPowerCard
114
+                        case .consumptionMonitor:
115
+                            consumptionMonitorSetupCard
105 116
                         }
106 117
                     }
107 118
                     .padding()
@@ -116,10 +127,22 @@ struct MeterChargeRecordContentView: View {
116 127
             )
117 128
             .ignoresSafeArea()
118 129
         )
130
+        .confirmationDialog(
131
+            "Stop and discard this session?",
132
+            isPresented: $discardConsumptionConfirmation,
133
+            titleVisibility: .visible
134
+        ) {
135
+            Button("Discard", role: .destructive) {
136
+                _ = appData.stopConsumptionMonitor(for: meterMACAddress, save: false)
137
+            }
138
+            Button("Cancel", role: .cancel) {}
139
+        } message: {
140
+            Text("The current session data will be lost and nothing will be saved.")
141
+        }
119 142
         .onAppear {
120 143
             syncDraftSelections()
121 144
         }
122
-        .onChange(of: selectedChargedDevice?.id) { _ in
145
+        .onChange(of: selectedChargeTargetID) { _ in
123 146
             syncDraftSelections()
124 147
         }
125 148
         .onChange(of: openChargeSession?.id) { _ in
@@ -134,37 +157,142 @@ struct MeterChargeRecordContentView: View {
134 157
     }
135 158
 
136 159
     private var selectedChargedDevice: ChargedDeviceSummary? {
137
-        appData.currentChargedDeviceSummary(for: meterMACAddress)
160
+        if let openChargeSession {
161
+            return appData.chargedDeviceSummary(id: openChargeSession.chargedDeviceID)
162
+        }
163
+
164
+        guard draftChargedPowerbankID == nil else { return nil }
165
+        guard let draftChargedDeviceID else { return nil }
166
+        let chargedDevice = appData.chargedDeviceSummary(id: draftChargedDeviceID)
167
+        return chargedDevice?.isCharger == false ? chargedDevice : nil
138 168
     }
139 169
 
140 170
     private var availableChargedDevices: [ChargedDeviceSummary] {
141 171
         appData.deviceSummaries
142 172
     }
143 173
 
144
-    private var selectedChargedDeviceID: Binding<UUID?> {
174
+    private var selectedChargedPowerbank: PowerbankSummary? {
175
+        if let openChargeSession,
176
+           let powerbankID = openChargeSession.chargedPowerbankID {
177
+            return appData.powerbankSummaries.first { $0.id == powerbankID }
178
+        }
179
+
180
+        guard let draftChargedPowerbankID else { return nil }
181
+        return appData.powerbankSummaries.first { $0.id == draftChargedPowerbankID }
182
+    }
183
+
184
+    private var selectedChargeTargetID: UUID? {
185
+        selectedChargedPowerbank?.id ?? selectedChargedDevice?.id
186
+    }
187
+
188
+    private var selectedChargeTargetTag: Binding<String> {
145 189
         Binding(
146
-            get: { selectedChargedDevice?.id },
190
+            get: {
191
+                if let openChargeSession {
192
+                    if let powerbankID = openChargeSession.chargedPowerbankID {
193
+                        return "powerbank:\(powerbankID.uuidString)"
194
+                    }
195
+                    return "device:\(openChargeSession.chargedDeviceID.uuidString)"
196
+                }
197
+                if let draftChargedPowerbankID {
198
+                    return "powerbank:\(draftChargedPowerbankID.uuidString)"
199
+                }
200
+                if let draftChargedDeviceID {
201
+                    return "device:\(draftChargedDeviceID.uuidString)"
202
+                }
203
+                return "none"
204
+            },
147 205
             set: { newValue in
148
-                guard let newValue else { return }
149
-                _ = appData.assignChargedDevice(newValue, to: meterMACAddress)
206
+                if newValue == "none" {
207
+                    draftChargedDeviceID = nil
208
+                    draftChargedPowerbankID = nil
209
+                    draftChargingTransportMode = nil
210
+                    draftChargingStateMode = nil
211
+                } else if newValue.hasPrefix("device:"),
212
+                          let uuid = UUID(uuidString: String(newValue.dropFirst("device:".count))) {
213
+                    draftChargedDeviceID = uuid
214
+                    draftChargedPowerbankID = nil
215
+                } else if newValue.hasPrefix("powerbank:"),
216
+                          let uuid = UUID(uuidString: String(newValue.dropFirst("powerbank:".count))) {
217
+                    draftChargedDeviceID = nil
218
+                    draftChargedPowerbankID = uuid
219
+                }
150 220
             }
151 221
         )
152 222
     }
153 223
 
154 224
     private var selectedCharger: ChargedDeviceSummary? {
155
-        appData.currentChargerSummary(for: meterMACAddress)
225
+        if let openChargeSession,
226
+           let chargerID = openChargeSession.chargerID {
227
+            return appData.chargedDeviceSummary(id: chargerID)
228
+        }
229
+
230
+        guard let draftChargerID else { return nil }
231
+        let charger = appData.chargedDeviceSummary(id: draftChargerID)
232
+        return charger?.isCharger == true ? charger : nil
156 233
     }
157 234
 
158 235
     private var availableChargers: [ChargedDeviceSummary] {
159 236
         appData.chargerSummaries
160 237
     }
161 238
 
239
+    private var availablePowerbanks: [PowerbankSummary] {
240
+        appData.powerbankSummaries
241
+    }
242
+
243
+    private var availableSourcePowerbanks: [PowerbankSummary] {
244
+        availablePowerbanks.filter { $0.id != selectedChargedPowerbank?.id }
245
+    }
246
+
247
+    private var selectedSourcePowerbank: PowerbankSummary? {
248
+        if let openChargeSession,
249
+           let powerbankID = openChargeSession.sourcePowerbankID {
250
+            return availablePowerbanks.first { $0.id == powerbankID }
251
+        }
252
+        guard let draftSourcePowerbankID else { return nil }
253
+        return availableSourcePowerbanks.first { $0.id == draftSourcePowerbankID }
254
+    }
255
+
256
+    /// Unified source selection encoding — packed into a String tag because SwiftUI Picker
257
+    /// works best with hashable primitives. `none`, `charger:UUID`, or `powerbank:UUID`.
258
+    private var selectedSourceTag: Binding<String> {
259
+        Binding(
260
+            get: {
261
+                if let openChargeSession {
262
+                    if let chargerID = openChargeSession.chargerID { return "charger:\(chargerID.uuidString)" }
263
+                    if let powerbankID = openChargeSession.sourcePowerbankID { return "powerbank:\(powerbankID.uuidString)" }
264
+                    return "none"
265
+                }
266
+                if let draftChargerID { return "charger:\(draftChargerID.uuidString)" }
267
+                if let draftSourcePowerbankID { return "powerbank:\(draftSourcePowerbankID.uuidString)" }
268
+                return "none"
269
+            },
270
+            set: { newValue in
271
+                if newValue == "none" {
272
+                    draftChargerID = nil
273
+                    draftSourcePowerbankID = nil
274
+                } else if newValue.hasPrefix("charger:"),
275
+                          let uuid = UUID(uuidString: String(newValue.dropFirst("charger:".count))) {
276
+                    draftChargerID = uuid
277
+                    draftSourcePowerbankID = nil
278
+                } else if newValue.hasPrefix("powerbank:"),
279
+                          let uuid = UUID(uuidString: String(newValue.dropFirst("powerbank:".count))) {
280
+                    draftChargerID = nil
281
+                    draftSourcePowerbankID = uuid
282
+                }
283
+            }
284
+        )
285
+    }
286
+
287
+    private var hasAnySource: Bool {
288
+        availableChargers.isEmpty == false || availableSourcePowerbanks.isEmpty == false
289
+    }
290
+
162 291
     private var selectedChargerID: Binding<UUID?> {
163 292
         Binding(
164
-            get: { selectedCharger?.id },
293
+            get: { openChargeSession?.chargerID ?? draftChargerID },
165 294
             set: { newValue in
166
-                guard let newValue else { return }
167
-                _ = appData.assignCharger(newValue, to: meterMACAddress)
295
+                draftChargerID = newValue
168 296
             }
169 297
         )
170 298
     }
@@ -173,6 +301,15 @@ struct MeterChargeRecordContentView: View {
173 301
         appData.activeChargeSessionSummary(for: meterMACAddress)
174 302
     }
175 303
 
304
+    private var activeConsumptionSession: ConsumptionMonitorLiveSession? {
305
+        appData.consumptionMonitorSession(for: meterMACAddress)
306
+    }
307
+
308
+    private var draftConsumptionDevice: ChargedDeviceSummary? {
309
+        guard let id = draftConsumptionDeviceID else { return nil }
310
+        return availableChargedDevices.first { $0.id == id }
311
+    }
312
+
176 313
     private var showsMeterTotalsCard: Bool {
177 314
         usbMeter.supportsRecordingView
178 315
             || usbMeter.supportsDataGroupCommands
@@ -221,6 +358,17 @@ struct MeterChargeRecordContentView: View {
221 358
             requirements.append(.existingSession)
222 359
         }
223 360
 
361
+        if selectedChargedPowerbank != nil {
362
+            if shouldRequireInitialCheckpoint {
363
+                if hasInitialCheckpointInput == false {
364
+                    requirements.append(.initialCheckpointEmpty)
365
+                } else if initialCheckpointValue == nil {
366
+                    requirements.append(.initialCheckpointInvalid)
367
+                }
368
+            }
369
+            return requirements
370
+        }
371
+
224 372
         guard let selectedChargedDevice else {
225 373
             requirements.append(.device)
226 374
             return requirements
@@ -283,6 +431,30 @@ struct MeterChargeRecordContentView: View {
283 431
         return transportMode == .wireless
284 432
     }
285 433
 
434
+    /// Source section is visible whenever a transport is picked. For wired sessions only
435
+    /// powerbanks are listed (chargers don't apply); for wireless both chargers and powerbanks
436
+    /// can be picked.
437
+    private var showsSourceSection: Bool {
438
+        guard selectedDraftTransportMode != nil || selectedChargedDevice != nil else { return false }
439
+        if showsWirelessChargerSection {
440
+            return hasAnySource
441
+        }
442
+        return availableSourcePowerbanks.isEmpty == false
443
+    }
444
+
445
+    private var sourceSectionListsChargers: Bool {
446
+        showsWirelessChargerSection
447
+    }
448
+
449
+    private var sourcePromptText: String {
450
+        if showsWirelessChargerSection {
451
+            return availableChargers.isEmpty && availablePowerbanks.isEmpty
452
+                ? "No source available"
453
+                : "Choose source"
454
+        }
455
+        return availableSourcePowerbanks.isEmpty ? "No powerbank available" : "Choose powerbank (optional)"
456
+    }
457
+
286 458
     // MARK: - Status Header
287 459
 
288 460
     private var statusHeader: some View {
@@ -310,6 +482,7 @@ struct MeterChargeRecordContentView: View {
310 482
         Picker("", selection: $activeMode) {
311 483
             Label("Charge Session", systemImage: "bolt.fill").tag(ActiveMode.chargeSession)
312 484
             Label("Standby Power", systemImage: "powersleep").tag(ActiveMode.standbyPower)
485
+            Label("Consumption", systemImage: "chart.line.uptrend.xyaxis").tag(ActiveMode.consumptionMonitor)
313 486
         }
314 487
         .pickerStyle(.segmented)
315 488
         .labelsHidden()
@@ -321,18 +494,24 @@ struct MeterChargeRecordContentView: View {
321 494
         VStack(alignment: .leading, spacing: 0) {
322 495
             // Device
323 496
             setupRow(icon: "iphone", iconColor: .blue) {
324
-                Picker(selection: selectedChargedDeviceID) {
325
-                    Text("Choose device").tag(UUID?.none)
497
+                Picker(selection: selectedChargeTargetTag) {
498
+                    Text("Choose target").tag("none")
326 499
                     ForEach(availableChargedDevices) { device in
327
-                        Text(device.name).tag(Optional(device.id))
500
+                        Text(device.name).tag("device:\(device.id.uuidString)")
501
+                    }
502
+                    ForEach(availablePowerbanks) { powerbank in
503
+                        Text("Powerbank · \(powerbank.name)").tag("powerbank:\(powerbank.id.uuidString)")
328 504
                     }
329 505
                 } label: {
330 506
                     HStack(spacing: 8) {
331 507
                         if let device = selectedChargedDevice {
332 508
                             ChargedDeviceIdentityLabelView(chargedDevice: device, iconPointSize: 15)
333 509
                                 .font(.subheadline.weight(.semibold))
510
+                        } else if let powerbank = selectedChargedPowerbank {
511
+                            Label(powerbank.name, systemImage: powerbank.identitySymbolName)
512
+                                .font(.subheadline.weight(.semibold))
334 513
                         } else {
335
-                            Text(availableChargedDevices.isEmpty ? "No devices available" : "Choose device")
514
+                            Text(availableChargedDevices.isEmpty && availablePowerbanks.isEmpty ? "No targets available" : "Choose target")
336 515
                                 .foregroundColor(.secondary)
337 516
                                 .font(.subheadline)
338 517
                         }
@@ -343,7 +522,7 @@ struct MeterChargeRecordContentView: View {
343 522
                     }
344 523
                 }
345 524
                 .pickerStyle(.menu)
346
-                .disabled(availableChargedDevices.isEmpty)
525
+                .disabled(availableChargedDevices.isEmpty && availablePowerbanks.isEmpty)
347 526
             }
348 527
 
349 528
             // Charging type — only when device supports multiple
@@ -367,43 +546,31 @@ struct MeterChargeRecordContentView: View {
367 546
                 }
368 547
             }
369 548
 
370
-            // Charging state — only when device supports multiple
371
-            if requiresExplicitChargingStateSelection, let device = selectedChargedDevice {
372
-                Divider().padding(.leading, 46)
373
-                setupRow(icon: draftChargingStateMode == .off ? "power.circle" : "power", iconColor: .purple) {
374
-                    Text("Mode")
375
-                        .foregroundColor(.secondary)
376
-                        .font(.subheadline)
377
-                    Spacer()
378
-                    compactSelectionMenu(
379
-                        title: draftChargingStateMode?.title ?? "Choose",
380
-                        options: device.supportedChargingStateModes.map { mode in
381
-                            CompactSelectionOption(
382
-                                id: mode.id, title: mode.title,
383
-                                isSelected: draftChargingStateMode == mode,
384
-                                action: { draftChargingStateMode = mode }
385
-                            )
386
-                        }
387
-                    )
388
-                }
389
-            }
390
-
391
-            // Wireless charger — only when wireless transport
392
-            if showsWirelessChargerSection {
549
+            // Source — charger (when wireless) and/or powerbank. None is always allowed.
550
+            if showsSourceSection {
393 551
                 Divider().padding(.leading, 46)
552
+                    .transition(.opacity)
394 553
                 setupRow(icon: "antenna.radiowaves.left.and.right", iconColor: .teal) {
395
-                    Picker(selection: selectedChargerID) {
396
-                        Text("Choose charger").tag(UUID?.none)
397
-                        ForEach(availableChargers) { charger in
398
-                            Text(charger.name).tag(Optional(charger.id))
554
+                    Picker(selection: selectedSourceTag) {
555
+                        Text("None").tag("none")
556
+                        if sourceSectionListsChargers {
557
+                            ForEach(availableChargers) { charger in
558
+                                Text("Charger · \(charger.name)").tag("charger:\(charger.id.uuidString)")
559
+                            }
560
+                        }
561
+                        ForEach(availableSourcePowerbanks) { powerbank in
562
+                            Text("Powerbank · \(powerbank.name)").tag("powerbank:\(powerbank.id.uuidString)")
399 563
                         }
400 564
                     } label: {
401 565
                         HStack(spacing: 8) {
402 566
                             if let charger = selectedCharger {
403 567
                                 ChargedDeviceIdentityLabelView(chargedDevice: charger, iconPointSize: 15)
404 568
                                     .font(.subheadline.weight(.semibold))
569
+                            } else if let powerbank = selectedSourcePowerbank {
570
+                                Label(powerbank.name, systemImage: powerbank.identitySymbolName)
571
+                                    .font(.subheadline.weight(.semibold))
405 572
                             } else {
406
-                                Text(availableChargers.isEmpty ? "No chargers available" : "Choose charger")
573
+                                Text(sourcePromptText)
407 574
                                     .foregroundColor(.secondary)
408 575
                                     .font(.subheadline)
409 576
                             }
@@ -414,7 +581,31 @@ struct MeterChargeRecordContentView: View {
414 581
                         }
415 582
                     }
416 583
                     .pickerStyle(.menu)
417
-                    .disabled(availableChargers.isEmpty)
584
+                }
585
+                .transition(.asymmetric(
586
+                    insertion: .move(edge: .top).combined(with: .opacity),
587
+                    removal: .opacity
588
+                ))
589
+            }
590
+
591
+            // Charging state — only when device supports multiple
592
+            if requiresExplicitChargingStateSelection, let device = selectedChargedDevice {
593
+                Divider().padding(.leading, 46)
594
+                setupRow(icon: draftChargingStateMode == .off ? "power.circle" : "power", iconColor: .purple) {
595
+                    Text("Mode")
596
+                        .foregroundColor(.secondary)
597
+                        .font(.subheadline)
598
+                    Spacer()
599
+                    compactSelectionMenu(
600
+                        title: draftChargingStateMode?.title ?? "Choose",
601
+                        options: device.supportedChargingStateModes.map { mode in
602
+                            CompactSelectionOption(
603
+                                id: mode.id, title: mode.title,
604
+                                isSelected: draftChargingStateMode == mode,
605
+                                action: { draftChargingStateMode = mode }
606
+                            )
607
+                        }
608
+                    )
418 609
                 }
419 610
             }
420 611
 
@@ -487,9 +678,207 @@ struct MeterChargeRecordContentView: View {
487 678
             .buttonStyle(.plain)
488 679
             .disabled(!canStartSession)
489 680
         }
681
+        .animation(.spring(response: 0.35, dampingFraction: 0.8), value: showsWirelessChargerSection)
490 682
         .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20)
491 683
     }
492 684
 
685
+    // MARK: - Consumption Monitor
686
+
687
+    private var consumptionMonitorSetupCard: some View {
688
+        VStack(alignment: .leading, spacing: 0) {
689
+            setupRow(icon: "iphone", iconColor: .purple) {
690
+                Picker(selection: $draftConsumptionDeviceID) {
691
+                    Text(availableChargedDevices.isEmpty ? "No devices available" : "Choose device")
692
+                        .tag(Optional<UUID>.none)
693
+                    ForEach(availableChargedDevices) { device in
694
+                        Text(device.name).tag(Optional(device.id))
695
+                    }
696
+                } label: {
697
+                    HStack(spacing: 8) {
698
+                        if let device = draftConsumptionDevice {
699
+                            ChargedDeviceIdentityLabelView(chargedDevice: device, iconPointSize: 15)
700
+                                .font(.subheadline.weight(.semibold))
701
+                        } else {
702
+                            Text(availableChargedDevices.isEmpty ? "No devices available" : "Choose device")
703
+                                .foregroundColor(.secondary)
704
+                                .font(.subheadline)
705
+                        }
706
+                        Spacer(minLength: 8)
707
+                        Image(systemName: "chevron.up.chevron.down")
708
+                            .font(.caption.weight(.semibold))
709
+                            .foregroundColor(.secondary)
710
+                    }
711
+                }
712
+                .pickerStyle(.menu)
713
+                .disabled(availableChargedDevices.isEmpty)
714
+            }
715
+
716
+            Divider()
717
+            Button("Start Session") {
718
+                startConsumptionSession()
719
+            }
720
+            .frame(maxWidth: .infinity)
721
+            .padding(.vertical, 11)
722
+            .font(.subheadline.weight(.semibold))
723
+            .foregroundColor(draftConsumptionDeviceID != nil ? .purple : .secondary)
724
+            .buttonStyle(.plain)
725
+            .disabled(draftConsumptionDeviceID == nil)
726
+        }
727
+        .meterCard(tint: .purple, fillOpacity: 0.14, strokeOpacity: 0.20)
728
+    }
729
+
730
+    private func consumptionSessionActiveView(_ session: ConsumptionMonitorLiveSession) -> some View {
731
+        ScrollView {
732
+            VStack(spacing: 14) {
733
+                consumptionSessionHeaderCard
734
+                liveMeterStripView
735
+                consumptionSessionInfoCard(session)
736
+                if session.cumulativeEnergyWh > 0 {
737
+                    consumptionProjectionsCard(session)
738
+                }
739
+            }
740
+            .padding()
741
+        }
742
+    }
743
+
744
+    private var consumptionSessionHeaderCard: some View {
745
+        HStack {
746
+            Image(systemName: "chart.line.uptrend.xyaxis")
747
+                .foregroundColor(.purple)
748
+            Text("Consumption Monitor")
749
+                .font(.system(.title3, design: .rounded).weight(.bold))
750
+            Spacer()
751
+            Text("Running")
752
+                .font(.caption.weight(.bold))
753
+                .foregroundColor(.green)
754
+                .padding(.horizontal, 10)
755
+                .padding(.vertical, 6)
756
+                .meterCard(tint: .green, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
757
+        }
758
+        .padding(.horizontal, 18)
759
+        .padding(.vertical, 12)
760
+        .meterCard(tint: .purple, fillOpacity: 0.18, strokeOpacity: 0.24)
761
+    }
762
+
763
+    private func consumptionSessionInfoCard(_ session: ConsumptionMonitorLiveSession) -> some View {
764
+        VStack(alignment: .leading, spacing: 0) {
765
+            if let device = appData.chargedDeviceSummary(id: session.chargedDeviceID) {
766
+                setupRow(icon: "iphone", iconColor: .purple) {
767
+                    ChargedDeviceIdentityLabelView(chargedDevice: device, iconPointSize: 15)
768
+                        .font(.subheadline.weight(.semibold))
769
+                    Spacer()
770
+                }
771
+                Divider().padding(.leading, 46)
772
+            }
773
+
774
+            setupRow(icon: "clock", iconColor: .secondary) {
775
+                Text("Duration")
776
+                    .foregroundColor(.secondary)
777
+                    .font(.subheadline)
778
+                Spacer()
779
+                Text(consumptionDurationText(session.elapsedDuration))
780
+                    .font(.subheadline.weight(.semibold))
781
+                    .monospacedDigit()
782
+            }
783
+
784
+            Divider().padding(.leading, 46)
785
+
786
+            setupRow(icon: "waveform", iconColor: .secondary) {
787
+                Text("Samples")
788
+                    .foregroundColor(.secondary)
789
+                    .font(.subheadline)
790
+                Spacer()
791
+                Text("\(session.committedSampleCount) × 60 s")
792
+                    .font(.subheadline.weight(.semibold))
793
+                    .monospacedDigit()
794
+            }
795
+
796
+            Divider().padding(.leading, 46)
797
+
798
+            setupRow(icon: "bolt.fill", iconColor: .yellow) {
799
+                Text("Energy")
800
+                    .foregroundColor(.secondary)
801
+                    .font(.subheadline)
802
+                Spacer()
803
+                Text(consumptionEnergyText(session.cumulativeEnergyWh))
804
+                    .font(.subheadline.weight(.semibold))
805
+                    .monospacedDigit()
806
+            }
807
+
808
+            Divider()
809
+
810
+            HStack(spacing: 0) {
811
+                Button("Save & Stop") {
812
+                    _ = appData.stopConsumptionMonitor(for: meterMACAddress, save: true)
813
+                }
814
+                .frame(maxWidth: .infinity)
815
+                .padding(.vertical, 11)
816
+                .font(.subheadline.weight(.semibold))
817
+                .foregroundColor(session.committedSampleCount > 0 ? .green : .secondary)
818
+                .buttonStyle(.plain)
819
+                .disabled(session.committedSampleCount == 0)
820
+
821
+                Divider().frame(height: 42)
822
+
823
+                Button("Discard") {
824
+                    discardConsumptionConfirmation = true
825
+                }
826
+                .frame(maxWidth: .infinity)
827
+                .padding(.vertical, 11)
828
+                .font(.subheadline.weight(.semibold))
829
+                .foregroundColor(.red)
830
+                .buttonStyle(.plain)
831
+            }
832
+        }
833
+        .meterCard(tint: .purple, fillOpacity: 0.14, strokeOpacity: 0.20)
834
+    }
835
+
836
+    private func consumptionProjectionsCard(_ session: ConsumptionMonitorLiveSession) -> some View {
837
+        let avgPower = session.cumulativeEnergyWh / max(session.elapsedDuration / 3600, 0.001)
838
+        return VStack(alignment: .leading, spacing: 0) {
839
+            setupRow(icon: "chart.bar.fill", iconColor: .teal) {
840
+                Text("Avg Power")
841
+                    .foregroundColor(.secondary)
842
+                    .font(.subheadline)
843
+                Spacer()
844
+                Text("\(avgPower.format(decimalDigits: 3)) W")
845
+                    .font(.subheadline.weight(.semibold))
846
+                    .monospacedDigit()
847
+            }
848
+            Divider().padding(.leading, 46)
849
+            setupRow(icon: "calendar.day.timeline.right", iconColor: .teal) {
850
+                Text("24 Hours")
851
+                    .foregroundColor(.secondary)
852
+                    .font(.subheadline)
853
+                Spacer()
854
+                Text(consumptionEnergyText(avgPower * 24))
855
+                    .font(.subheadline.weight(.semibold))
856
+                    .monospacedDigit()
857
+            }
858
+            Divider().padding(.leading, 46)
859
+            setupRow(icon: "calendar", iconColor: .teal) {
860
+                Text("30 Days")
861
+                    .foregroundColor(.secondary)
862
+                    .font(.subheadline)
863
+                Spacer()
864
+                Text(consumptionEnergyText(avgPower * 24 * 30))
865
+                    .font(.subheadline.weight(.semibold))
866
+                    .monospacedDigit()
867
+            }
868
+            Divider().padding(.leading, 46)
869
+            setupRow(icon: "calendar", iconColor: .teal) {
870
+                Text("1 Year")
871
+                    .foregroundColor(.secondary)
872
+                    .font(.subheadline)
873
+                Spacer()
874
+                Text(consumptionEnergyText(avgPower * 24 * 365))
875
+                    .font(.subheadline.weight(.semibold))
876
+                    .monospacedDigit()
877
+            }
878
+        }
879
+        .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20)
880
+    }
881
+
493 882
     // MARK: - Standby Power Card
494 883
 
495 884
     private var standbyPowerCard: some View {
@@ -628,6 +1017,22 @@ struct MeterChargeRecordContentView: View {
628 1017
     }
629 1018
 
630 1019
     private func startSession() {
1020
+        if let selectedChargedPowerbank {
1021
+            let didStart = appData.startPowerbankChargeSession(
1022
+                for: usbMeter,
1023
+                powerbankID: selectedChargedPowerbank.id,
1024
+                sourcePowerbankID: selectedSourcePowerbank?.id,
1025
+                initialBatteryPercent: initialCheckpointMode == .known ? initialCheckpointValue : nil,
1026
+                startsFromFlatBattery: initialCheckpointMode == .flat
1027
+            )
1028
+
1029
+            if didStart {
1030
+                initialCheckpoint = ""
1031
+                initialCheckpointMode = .known
1032
+            }
1033
+            return
1034
+        }
1035
+
631 1036
         guard let selectedChargedDevice,
632 1037
               let chargingTransportMode = selectedDraftTransportMode,
633 1038
               let chargingStateMode = selectedDraftChargingStateMode else {
@@ -635,10 +1040,12 @@ struct MeterChargeRecordContentView: View {
635 1040
         }
636 1041
 
637 1042
         let chargerID = chargingTransportMode == .wireless ? selectedCharger?.id : nil
1043
+        let powerbankSourceID = selectedSourcePowerbank?.id
638 1044
         let didStart = appData.startChargeSession(
639 1045
             for: usbMeter,
640 1046
             chargedDeviceID: selectedChargedDevice.id,
641 1047
             chargerID: chargerID,
1048
+            sourcePowerbankID: powerbankSourceID,
642 1049
             chargingTransportMode: chargingTransportMode,
643 1050
             chargingStateMode: chargingStateMode,
644 1051
             autoStopEnabled: false,
@@ -652,6 +1059,11 @@ struct MeterChargeRecordContentView: View {
652 1059
         }
653 1060
     }
654 1061
 
1062
+    private func startConsumptionSession() {
1063
+        guard let deviceID = draftConsumptionDeviceID else { return }
1064
+        _ = appData.startConsumptionMonitor(for: deviceID, on: usbMeter)
1065
+    }
1066
+
655 1067
     private func adjustInitialCheckpoint(by delta: Double) {
656 1068
         guard initialCheckpointMode == .known else { return }
657 1069
         let currentValue = initialCheckpointValue ?? 0
@@ -660,6 +1072,15 @@ struct MeterChargeRecordContentView: View {
660 1072
     }
661 1073
 
662 1074
     private func syncDraftSelections() {
1075
+        if selectedChargedPowerbank != nil {
1076
+            draftChargingTransportMode = .wired
1077
+            draftChargingStateMode = .on
1078
+            if draftSourcePowerbankID == selectedChargedPowerbank?.id {
1079
+                draftSourcePowerbankID = nil
1080
+            }
1081
+            return
1082
+        }
1083
+
663 1084
         guard let selectedChargedDevice else {
664 1085
             draftChargingTransportMode = nil
665 1086
             draftChargingStateMode = nil
@@ -733,4 +1154,18 @@ struct MeterChargeRecordContentView: View {
733 1154
         }
734 1155
         .buttonStyle(.plain)
735 1156
     }
1157
+
1158
+    private func consumptionDurationText(_ duration: TimeInterval) -> String {
1159
+        let formatter = DateComponentsFormatter()
1160
+        formatter.allowedUnits = duration >= 3600 ? [.hour, .minute] : [.minute, .second]
1161
+        formatter.unitsStyle = .abbreviated
1162
+        formatter.zeroFormattingBehavior = .pad
1163
+        return formatter.string(from: max(duration, 0)) ?? "0m"
1164
+    }
1165
+
1166
+    private func consumptionEnergyText(_ wattHours: Double) -> String {
1167
+        wattHours >= 1000
1168
+            ? "\((wattHours / 1000).format(decimalDigits: 3)) kWh"
1169
+            : "\(wattHours.format(decimalDigits: 2)) Wh"
1170
+    }
736 1171
 }
+2 -0
USB Meter/Views/MeterMappingDebugView.swift
@@ -53,6 +53,7 @@ struct MeterMappingDebugView: View {
53 53
         }
54 54
         .listStyle(.insetGrouped)
55 55
         .navigationTitle("Meter Sync Debug")
56
+        .navigationBarTitleDisplayMode(.inline)
56 57
         .onAppear(perform: reload)
57 58
         .onReceive(changePublisher) { _ in reload() }
58 59
         .toolbar {
@@ -60,6 +61,7 @@ struct MeterMappingDebugView: View {
60 61
                 reload()
61 62
             }
62 63
         }
64
+        .sidebarToggleToolbarItem()
63 65
     }
64 66
 
65 67
     private func reload() {
+162 -0
USB Meter/Views/Powerbanks/PowerbankDetailView.swift
@@ -0,0 +1,162 @@
1
+//
2
+//  PowerbankDetailView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct PowerbankDetailView: View {
9
+    @EnvironmentObject private var appData: AppData
10
+    @Environment(\.dismiss) private var dismiss
11
+
12
+    let powerbankID: UUID
13
+
14
+    @State private var isEditing = false
15
+    @State private var pendingDeletion = false
16
+
17
+    private var powerbank: PowerbankSummary? {
18
+        appData.powerbankSummaries.first { $0.id == powerbankID }
19
+    }
20
+
21
+    var body: some View {
22
+        Group {
23
+            if let powerbank {
24
+                content(for: powerbank)
25
+            } else {
26
+                Text("Powerbank not found.")
27
+                    .foregroundColor(.secondary)
28
+            }
29
+        }
30
+        .navigationTitle(powerbank?.name ?? "Powerbank")
31
+        .navigationBarTitleDisplayMode(.inline)
32
+        .toolbar {
33
+            ToolbarItem(placement: .navigationBarTrailing) {
34
+                Menu {
35
+                    Button("Edit") { isEditing = true }
36
+                    Button("Delete", role: .destructive) { pendingDeletion = true }
37
+                } label: {
38
+                    Image(systemName: "ellipsis.circle")
39
+                }
40
+                .disabled(powerbank == nil)
41
+            }
42
+        }
43
+        .sheet(isPresented: $isEditing) {
44
+            if let powerbank {
45
+                PowerbankEditorSheetView(powerbank: powerbank)
46
+                    .environmentObject(appData)
47
+            }
48
+        }
49
+        .confirmationDialog(
50
+            "Delete \(powerbank?.name ?? "powerbank")?",
51
+            isPresented: $pendingDeletion,
52
+            titleVisibility: .visible
53
+        ) {
54
+            Button("Delete", role: .destructive) {
55
+                if let powerbank {
56
+                    _ = appData.deletePowerbank(id: powerbank.id)
57
+                    dismiss()
58
+                }
59
+            }
60
+            Button("Cancel", role: .cancel) {}
61
+        } message: {
62
+            Text("This will permanently remove the powerbank and any sessions where it is the subject. Sessions where it was the source will keep their data, with the source link cleared.")
63
+        }
64
+    }
65
+
66
+    @ViewBuilder
67
+    private func content(for powerbank: PowerbankSummary) -> some View {
68
+        Form {
69
+            Section(header: Text("Identity")) {
70
+                row("Name", value: powerbank.name)
71
+                HStack {
72
+                    Text("QR identifier")
73
+                    Spacer()
74
+                    Text(powerbank.qrIdentifier)
75
+                        .font(.caption.monospaced())
76
+                        .foregroundColor(.secondary)
77
+                        .textSelection(.enabled)
78
+                }
79
+            }
80
+
81
+            Section(header: Text("Battery")) {
82
+                row("Reporting", value: powerbank.batteryLevelReporting.title)
83
+                if powerbank.batteryLevelReporting == .bars {
84
+                    row("Bars resolution", value: "\(powerbank.batteryBarsCount)")
85
+                }
86
+                if let estimatedCapacityWh = powerbank.estimatedBatteryCapacityWh {
87
+                    row("Capacity (charged)", value: "\(estimatedCapacityWh.format(decimalDigits: 2)) Wh")
88
+                }
89
+                if let apparentCapacityWh = powerbank.apparentCapacityWh {
90
+                    row("Apparent capacity (delivered)", value: "\(apparentCapacityWh.format(decimalDigits: 2)) Wh")
91
+                }
92
+                if let efficiency = powerbank.sourceEfficiencyFactor {
93
+                    row("Efficiency", value: "\((efficiency * 100).format(decimalDigits: 1))%")
94
+                }
95
+            }
96
+
97
+            Section(header: Text("Source statistics")) {
98
+                if powerbank.sessionsAsSource.isEmpty {
99
+                    Text("No discharge sessions recorded yet.")
100
+                        .font(.caption)
101
+                        .foregroundColor(.secondary)
102
+                } else {
103
+                    row("Sessions as source", value: "\(powerbank.sessionsAsSource.count)")
104
+                    row("Total Wh delivered", value: "\(powerbank.totalDeliveredEnergyWh.format(decimalDigits: 2)) Wh")
105
+                    if let maxPower = powerbank.sourceMaximumPowerWatts {
106
+                        row("Max power", value: "\(maxPower.format(decimalDigits: 2)) W")
107
+                    }
108
+                    if powerbank.sourceVoltageMaxCurrents.isEmpty == false {
109
+                        VStack(alignment: .leading, spacing: 4) {
110
+                            Text("Voltage profile")
111
+                                .font(.subheadline.weight(.semibold))
112
+                            ForEach(powerbank.sourceVoltageMaxCurrents.keys.sorted(), id: \.self) { voltage in
113
+                                let maxAmps = powerbank.sourceVoltageMaxCurrents[voltage] ?? 0
114
+                                HStack {
115
+                                    Text(String(format: "%.1f V", voltage))
116
+                                        .font(.caption.monospaced())
117
+                                    Spacer()
118
+                                    Text("max \(maxAmps.format(decimalDigits: 2)) A")
119
+                                        .font(.caption)
120
+                                        .foregroundColor(.secondary)
121
+                                }
122
+                            }
123
+                        }
124
+                    } else if powerbank.sourceObservedVoltageSelections.isEmpty == false {
125
+                        row(
126
+                            "Observed voltages",
127
+                            value: powerbank.sourceObservedVoltageSelections
128
+                                .map { String(format: "%.1fV", $0) }
129
+                                .joined(separator: ", ")
130
+                        )
131
+                    }
132
+                }
133
+            }
134
+
135
+            Section(header: Text("Charging history")) {
136
+                if powerbank.sessionsAsSubject.isEmpty {
137
+                    Text("Not charged yet.")
138
+                        .font(.caption)
139
+                        .foregroundColor(.secondary)
140
+                } else {
141
+                    row("Charge sessions", value: "\(powerbank.sessionsAsSubject.count)")
142
+                    row("Total Wh received", value: "\(powerbank.totalReceivedEnergyWh.format(decimalDigits: 2)) Wh")
143
+                }
144
+            }
145
+
146
+            if let notes = powerbank.notes, !notes.isEmpty {
147
+                Section(header: Text("Notes")) {
148
+                    Text(notes)
149
+                }
150
+            }
151
+        }
152
+    }
153
+
154
+    private func row(_ label: String, value: String) -> some View {
155
+        HStack {
156
+            Text(label)
157
+            Spacer()
158
+            Text(value)
159
+                .foregroundColor(.secondary)
160
+        }
161
+    }
162
+}
+121 -0
USB Meter/Views/Powerbanks/PowerbankEditorSheetView.swift
@@ -0,0 +1,121 @@
1
+//
2
+//  PowerbankEditorSheetView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct PowerbankEditorSheetView: View {
9
+    @EnvironmentObject private var appData: AppData
10
+    @Environment(\.dismiss) private var dismiss
11
+
12
+    let powerbank: PowerbankSummary?
13
+    let standalone: Bool
14
+
15
+    @State private var name: String
16
+    @State private var batteryLevelReporting: BatteryLevelReporting
17
+    @State private var batteryBarsCount: Int
18
+    @State private var notes: String
19
+
20
+    init(
21
+        powerbank: PowerbankSummary? = nil,
22
+        standalone: Bool = true
23
+    ) {
24
+        self.powerbank = powerbank
25
+        self.standalone = standalone
26
+        _name = State(initialValue: powerbank?.name ?? "")
27
+        _batteryLevelReporting = State(initialValue: powerbank?.batteryLevelReporting ?? .percent)
28
+        _batteryBarsCount = State(initialValue: powerbank?.batteryBarsCount ?? 4)
29
+        _notes = State(initialValue: powerbank?.notes ?? "")
30
+    }
31
+
32
+    var body: some View {
33
+        ChargedDeviceEditorScaffoldView(
34
+            title: editorTitle,
35
+            saveButtonTitle: saveButtonTitle,
36
+            canSave: canSave,
37
+            standalone: standalone,
38
+            save: save
39
+        ) {
40
+            Section(header: Text("Identity")) {
41
+                TextField("Powerbank name", text: $name)
42
+
43
+                if let powerbank {
44
+                    Text(powerbank.qrIdentifier)
45
+                        .font(.caption.monospaced())
46
+                        .foregroundColor(.secondary)
47
+                        .textSelection(.enabled)
48
+                }
49
+            }
50
+
51
+            Section(
52
+                header: ContextInfoHeader(
53
+                    title: "Battery Level Reporting",
54
+                    message: "Powerbanks report battery in different ways: 0–100%, discrete bars (e.g. 4 of 4), a single LED that lights only when full, or not at all. This selection drives how checkpoints are entered and how capacity is learned."
55
+                )
56
+            ) {
57
+                Picker("Reporting", selection: $batteryLevelReporting) {
58
+                    ForEach(BatteryLevelReporting.allCases) { reporting in
59
+                        Text(reporting.title).tag(reporting)
60
+                    }
61
+                }
62
+                .pickerStyle(.menu)
63
+
64
+                if batteryLevelReporting == .bars {
65
+                    Stepper(value: $batteryBarsCount, in: 1...10) {
66
+                        Text("Bars resolution: \(batteryBarsCount)")
67
+                    }
68
+                }
69
+
70
+                Text(batteryLevelReporting.description)
71
+                    .font(.caption)
72
+                    .foregroundColor(.secondary)
73
+            }
74
+
75
+            Section(header: Text("Notes")) {
76
+                TextField("Optional notes", text: $notes)
77
+            }
78
+        }
79
+    }
80
+
81
+    private var editorTitle: String {
82
+        powerbank == nil ? "New Powerbank" : "Edit Powerbank"
83
+    }
84
+
85
+    private var saveButtonTitle: String {
86
+        powerbank == nil ? "Save" : "Update"
87
+    }
88
+
89
+    private var canSave: Bool {
90
+        !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
91
+    }
92
+
93
+    private func save() {
94
+        let trimmedNotes = notes.trimmingCharacters(in: .whitespacesAndNewlines)
95
+        let notesValue: String? = trimmedNotes.isEmpty ? nil : trimmedNotes
96
+
97
+        let didSave: Bool
98
+        if let powerbank {
99
+            didSave = appData.updatePowerbank(
100
+                id: powerbank.id,
101
+                name: name,
102
+                templateID: powerbank.deviceTemplateID,
103
+                batteryLevelReporting: batteryLevelReporting,
104
+                batteryBarsCount: batteryBarsCount,
105
+                notes: notesValue
106
+            )
107
+        } else {
108
+            didSave = appData.createPowerbank(
109
+                name: name,
110
+                templateID: nil,
111
+                batteryLevelReporting: batteryLevelReporting,
112
+                batteryBarsCount: batteryBarsCount,
113
+                notes: notesValue
114
+            )
115
+        }
116
+
117
+        if didSave {
118
+            dismiss()
119
+        }
120
+    }
121
+}
+0 -0
USB Meter/Views/ChargedDevices/Components/ChargedDeviceSidebarCardView.swift → USB Meter/Views/Sidebar/ChargedDeviceSidebarCardView.swift
File renamed without changes.
+69 -0
USB Meter/Views/Sidebar/PowerbankSidebarCardView.swift
@@ -0,0 +1,69 @@
1
+//
2
+//  PowerbankSidebarCardView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct PowerbankSidebarCardView: View {
9
+    let powerbank: PowerbankSummary
10
+
11
+    var body: some View {
12
+        HStack(alignment: .top, spacing: 12) {
13
+            ChargedDeviceQRCodeView(qrIdentifier: powerbank.qrIdentifier, side: 54)
14
+
15
+            VStack(alignment: .leading, spacing: 6) {
16
+                header
17
+                Text(powerbank.identityTitle)
18
+                    .font(.caption.weight(.semibold))
19
+                    .foregroundColor(.secondary)
20
+                details
21
+            }
22
+        }
23
+        .padding(.vertical, 4)
24
+    }
25
+
26
+    private var header: some View {
27
+        HStack {
28
+            Label(powerbank.name, systemImage: powerbank.identitySymbolName)
29
+                .font(.headline)
30
+
31
+            if powerbank.openSession != nil {
32
+                Spacer()
33
+                Text("Live")
34
+                    .font(.caption.weight(.bold))
35
+                    .foregroundColor(.green)
36
+            }
37
+        }
38
+    }
39
+
40
+    @ViewBuilder
41
+    private var details: some View {
42
+        Text(reportingSummary)
43
+            .font(.caption2)
44
+            .foregroundColor(.secondary)
45
+
46
+        if let capacityWh = powerbank.apparentCapacityWh ?? powerbank.estimatedBatteryCapacityWh {
47
+            Text("Capacity: \(capacityWh.format(decimalDigits: 2)) Wh")
48
+                .font(.caption2)
49
+                .foregroundColor(.secondary)
50
+        } else {
51
+            Text("Capacity: learning")
52
+                .font(.caption2)
53
+                .foregroundColor(.secondary)
54
+        }
55
+    }
56
+
57
+    private var reportingSummary: String {
58
+        switch powerbank.batteryLevelReporting {
59
+        case .percent:
60
+            return "Battery: 0–100%"
61
+        case .bars:
62
+            return "Battery: \(powerbank.batteryBarsCount) bars"
63
+        case .fullOnly:
64
+            return "Battery: full-only LED"
65
+        case .none:
66
+            return "Battery: not reported"
67
+        }
68
+    }
69
+}
+7 -6
USB Meter/Views/ChargedDevices/Sidebar/SidebarChargedDeviceLibraryView.swift → USB Meter/Views/Sidebar/SidebarChargedDeviceLibraryView.swift
@@ -38,6 +38,7 @@ struct SidebarChargedDeviceLibraryView: View {
38 38
                 Button("New") { showingNewEditor = true }
39 39
             }
40 40
         }
41
+        .sidebarToggleToolbarItem()
41 42
         .sheet(isPresented: $showingNewEditor) { newEditorSheet }
42 43
         .sheet(item: $editingChargedDevice) { device in editEditorSheet(device) }
43 44
         .confirmationDialog(
@@ -72,7 +73,7 @@ struct SidebarChargedDeviceLibraryView: View {
72 73
 
73 74
     private var deviceRows: some View {
74 75
         ForEach(displayedDevices) { device in
75
-            NavigationLink(destination: ChargedDeviceDetailView(chargedDeviceID: device.id)) {
76
+            NavigationLink(destination: ChargedDeviceSettingsView(chargedDeviceID: device.id)) {
76 77
                 ChargedDeviceLibraryRowView(chargedDevice: device, isSelected: false)
77 78
             }
78 79
             .swipeActions(edge: .trailing, allowsFullSwipe: false) {
@@ -109,10 +110,10 @@ struct SidebarChargedDeviceLibraryView: View {
109 110
     @ViewBuilder
110 111
     private var newEditorSheet: some View {
111 112
         if mode == .charger {
112
-            ChargerEditorSheetView(meterMACAddress: nil)
113
+            ChargerEditorSheetView()
113 114
                 .environmentObject(appData)
114 115
         } else {
115
-            ChargedDeviceEditorSheetView(meterMACAddress: nil)
116
+            ChargedDeviceEditorSheetView()
116 117
                 .environmentObject(appData)
117 118
         }
118 119
     }
@@ -123,7 +124,7 @@ struct SidebarChargedDeviceLibraryView: View {
123 124
             ChargerEditorSheetView(chargedDevice: device)
124 125
                 .environmentObject(appData)
125 126
         } else {
126
-            ChargedDeviceEditorSheetView(meterMACAddress: nil, chargedDevice: device)
127
+            ChargedDeviceEditorSheetView(chargedDevice: device)
127 128
                 .environmentObject(appData)
128 129
         }
129 130
     }
@@ -145,8 +146,8 @@ struct SidebarChargedDeviceLibraryView: View {
145 146
 
146 147
     private var emptyStateDescription: String {
147 148
         mode == .device
148
-            ? "Create one here, then select it before or during a charging session. The selected device becomes the default for this meter."
149
-            : "Create one here, then select it for wireless charging sessions. The selected charger becomes the default wireless source for this meter."
149
+            ? "Create one here, then select it explicitly when starting a charging session."
150
+            : "Create one here, then select it explicitly for wireless charging sessions or standby measurements."
150 151
     }
151 152
 
152 153
     private func deletePendingDevice() {
+1 -1
USB Meter/Views/ChargedDevices/Sidebar/SidebarChargedDevicesSectionView.swift → USB Meter/Views/Sidebar/SidebarChargedDevicesSectionView.swift
@@ -28,7 +28,7 @@ struct SidebarChargedDevicesSectionView: View {
28 28
                 .transition(.opacity.combined(with: .move(edge: .top)))
29 29
 
30 30
                 ForEach(chargedDevices) { chargedDevice in
31
-                    NavigationLink(destination: ChargedDeviceDetailView(chargedDeviceID: chargedDevice.id)) {
31
+                    NavigationLink(destination: ChargedDeviceSettingsView(chargedDeviceID: chargedDevice.id)) {
32 32
                         ChargedDeviceSidebarCardView(chargedDevice: chargedDevice)
33 33
                     }
34 34
                     .buttonStyle(.plain)
+67 -0
USB Meter/Views/Sidebar/SidebarPowerbanksSectionView.swift
@@ -0,0 +1,67 @@
1
+//
2
+//  SidebarPowerbanksSectionView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct SidebarPowerbanksSectionView: View {
9
+    let title: String
10
+    let powerbanks: [PowerbankSummary]
11
+    let emptyStateText: String
12
+    let tint: Color
13
+    let isExpanded: Bool
14
+    let onToggle: () -> Void
15
+    let onAdd: () -> Void
16
+
17
+    var body: some View {
18
+        Section(header: headerView) {
19
+            if isExpanded {
20
+                ForEach(powerbanks) { powerbank in
21
+                    NavigationLink(destination: PowerbankDetailView(powerbankID: powerbank.id)) {
22
+                        PowerbankSidebarCardView(powerbank: powerbank)
23
+                    }
24
+                    .buttonStyle(.plain)
25
+                    .transition(.opacity.combined(with: .move(edge: .top)))
26
+                }
27
+
28
+                if powerbanks.isEmpty {
29
+                    Text(emptyStateText)
30
+                        .font(.caption)
31
+                        .foregroundColor(.secondary)
32
+                        .padding(.vertical, 6)
33
+                        .transition(.opacity)
34
+                }
35
+            }
36
+        }
37
+    }
38
+
39
+    private var headerView: some View {
40
+        HStack(alignment: .firstTextBaseline, spacing: 10) {
41
+            Button(action: onToggle) {
42
+                HStack(alignment: .firstTextBaseline, spacing: 4) {
43
+                    Image(systemName: "chevron.right")
44
+                        .font(.caption.weight(.semibold))
45
+                        .foregroundColor(.secondary)
46
+                        .rotationEffect(.degrees(isExpanded ? 90 : 0))
47
+                        .animation(.easeInOut(duration: 0.22), value: isExpanded)
48
+                    Text(title)
49
+                        .font(.headline)
50
+                }
51
+            }
52
+            .buttonStyle(.plain)
53
+            Spacer()
54
+            Button(action: onAdd) {
55
+                Image(systemName: "plus.circle.fill")
56
+                    .font(.body.weight(.semibold))
57
+                    .foregroundColor(tint)
58
+            }
59
+            .buttonStyle(.plain)
60
+            Text("\(powerbanks.count)")
61
+                .font(.caption.weight(.bold))
62
+                .padding(.horizontal, 10)
63
+                .padding(.vertical, 6)
64
+                .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
65
+        }
66
+    }
67
+}
+22 -3
USB Meter/Views/Sidebar/SidebarView.swift
@@ -10,6 +10,7 @@ private enum SidebarCreationSheet: Identifiable {
10 10
     case meter
11 11
     case device
12 12
     case charger
13
+    case powerbank
13 14
 
14 15
     var id: String {
15 16
         switch self {
@@ -19,6 +20,8 @@ private enum SidebarCreationSheet: Identifiable {
19 20
             return "device"
20 21
         case .charger:
21 22
             return "charger"
23
+        case .powerbank:
24
+            return "powerbank"
22 25
         }
23 26
     }
24 27
 }
@@ -28,6 +31,7 @@ struct SidebarView: View {
28 31
     @State private var isUSBMetersExpanded = true
29 32
     @State private var isDevicesExpanded = true
30 33
     @State private var isChargersExpanded = true
34
+    @State private var isPowerbanksExpanded = true
31 35
     @State private var isHelpExpanded = false
32 36
     @State private var dismissedAutoHelpReason: SidebarHelpReason?
33 37
     @State private var now = Date()
@@ -61,13 +65,14 @@ struct SidebarView: View {
61 65
                 MeterEditorSheetView()
62 66
                     .environmentObject(appData)
63 67
             case .device:
64
-                ChargedDeviceEditorSheetView(
65
-                    meterMACAddress: nil
66
-                )
68
+                ChargedDeviceEditorSheetView()
67 69
                 .environmentObject(appData)
68 70
             case .charger:
69 71
                 ChargerEditorSheetView()
70 72
                     .environmentObject(appData)
73
+            case .powerbank:
74
+                PowerbankEditorSheetView()
75
+                    .environmentObject(appData)
71 76
             }
72 77
         }
73 78
     }
@@ -119,6 +124,20 @@ struct SidebarView: View {
119 124
                 },
120 125
                 onAdd: { creationSheet = .charger }
121 126
             )
127
+
128
+            SidebarPowerbanksSectionView(
129
+                title: "Powerbanks",
130
+                powerbanks: appData.powerbankSummaries,
131
+                emptyStateText: "No powerbanks yet. Add one here so charging sessions can track the powerbank as either subject or source.",
132
+                tint: .yellow,
133
+                isExpanded: isPowerbanksExpanded,
134
+                onToggle: {
135
+                    withAnimation(.easeInOut(duration: 0.22)) {
136
+                        isPowerbanksExpanded.toggle()
137
+                    }
138
+                },
139
+                onAdd: { creationSheet = .powerbank }
140
+            )
122 141
         }
123 142
     }
124 143