@@ -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 |
|
@@ -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) |
|
@@ -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 |
|
@@ -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) |
|
@@ -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/ |
|
@@ -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) |
|
@@ -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) |
|
@@ -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]] |
|
@@ -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 |
|
@@ -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) |
|
@@ -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) |
|
@@ -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 |
|
@@ -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 |
|
@@ -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) |
|
@@ -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 |
|
@@ -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. |
|
@@ -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. |
|
@@ -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 |
|
@@ -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` |
|
@@ -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/` |
@@ -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 |
|
@@ -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. |
|
@@ -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; |
@@ -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
|
@@ -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 |
|
@@ -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 |
-*/ |
|
@@ -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 {
|
@@ -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 |
} |
@@ -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 |
/** |
@@ -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 |
} |
@@ -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> |
@@ -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> |
|
@@ -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> |
|
@@ -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> |
|
@@ -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> |
|
@@ -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 |
+} |
|
@@ -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) {
|
@@ -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 |
+} |
|
@@ -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 |
|
@@ -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 |
} |
@@ -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 |
|
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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()) |
@@ -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 |
@@ -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 |
|
@@ -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 |
@@ -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 |
} |
@@ -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 |
} |
@@ -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 |
} |
@@ -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 |
|
@@ -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") {
|
@@ -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 |
+} |
|
@@ -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 {
|
@@ -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 |
} |
@@ -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) |
@@ -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 |
-} |
|
@@ -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 |
} |
@@ -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() {
|
@@ -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 |
+} |
|
@@ -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 +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 |
+} |
|
@@ -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() {
|
@@ -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) |
@@ -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 |
+} |
|
@@ -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 |
|