Two coordinated changes that share the v20 schema bump. Powerbank category (USB_Meter 19): first-class entity separate from ChargedDevice that can sit on either side of a session — subject when charged or source when supplying a device. Heterogeneous battery-level reporting (percent/bars/fullOnly/none), dual-subject checkpoints, view-time derived metrics (efficiency, voltage profile, max power, apparent capacity), unified Source picker. DeviceProfile refactor (USB_Meter 20): merges ChargedDeviceClass enforcement and the JSON template catalog into a single DeviceProfile entity. Profile carries category (taxonomy only), capabilities (allowed transports/state/wireless profiles), and defaults. - DeviceProfilesCatalog.json (18 seed profiles incl. AirPods Case, charging cases, MagSafe/Watch/Qi chargers) seeded into Core Data, gated by cloudProfileSeedVersion. - One-shot migration backfills profileID on every existing device and powerbank, synthesises custom DeviceProfile rows for devices without a catalog match, and promotes legacy ChargedDevice rows with deviceClass=powerbank into the new Powerbank entity. Gated by cloudDeviceProfileMigrationVersion. - Editor replaces class+template pickers with a single Profile picker grouped by category. Capability-driven autoexclusion hides forbidden toggles instead of silently overriding them at read time. New hasInternalSubject toggle visible only on case-style profiles. - Read normalisers (templateDefinition, supports*, chargingState availability, wirelessChargingProfile) now resolve through the profile, falling back to legacy class enforcement only for pre-migration rows. - Settings card surfaces "Subject: Inside/Empty" for case-style devices. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@@ -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` |
|
@@ -14,6 +14,8 @@ 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` |
@@ -74,6 +74,10 @@ |
||
| 74 | 74 |
CD0002053FA0000000000005 /* SidebarChargedDeviceLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0001053FA0000000000005 /* SidebarChargedDeviceLibraryView.swift */; };
|
| 75 | 75 |
CD0002063FA0000000000006 /* ChargedDeviceDetailTabBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0001063FA0000000000006 /* ChargedDeviceDetailTabBarView.swift */; };
|
| 76 | 76 |
CE00CE013C8E4A7A00CE00CE /* ChargerEditorSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE00CE023C8E4A7A00CE00CE /* ChargerEditorSheetView.swift */; };
|
| 77 |
+ A1B2C3D4E5F6A7B8C9D0E211 /* PowerbankEditorSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E201 /* PowerbankEditorSheetView.swift */; };
|
|
| 78 |
+ A1B2C3D4E5F6A7B8C9D0E212 /* PowerbankDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E202 /* PowerbankDetailView.swift */; };
|
|
| 79 |
+ A1B2C3D4E5F6A7B8C9D0E213 /* PowerbankSidebarCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E203 /* PowerbankSidebarCardView.swift */; };
|
|
| 80 |
+ A1B2C3D4E5F6A7B8C9D0E214 /* SidebarPowerbanksSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E204 /* SidebarPowerbanksSectionView.swift */; };
|
|
| 77 | 81 |
D28F11013C8E4A7A00A10011 /* MeterHomeTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11023C8E4A7A00A10012 /* MeterHomeTabView.swift */; };
|
| 78 | 82 |
D28F11033C8E4A7A00A10013 /* MeterLiveTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11043C8E4A7A00A10014 /* MeterLiveTabView.swift */; };
|
| 79 | 83 |
D28F11053C8E4A7A00A10015 /* MeterChartTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11063C8E4A7A00A10016 /* MeterChartTabView.swift */; };
|
@@ -95,6 +99,7 @@ |
||
| 95 | 99 |
D28F11433C8E4A7A00A10053 /* MeterDataGroupsTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11443C8E4A7A00A10054 /* MeterDataGroupsTabView.swift */; };
|
| 96 | 100 |
E430FB6B7CB3E0D4189F6D7D /* MeterMappingDebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56BA4CE53B6B2C4EBA42C81A /* MeterMappingDebugView.swift */; };
|
| 97 | 101 |
F20000036F5D4C95B6487F18 /* ChargingWindowDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000016F5D4C95B6487F16 /* ChargingWindowDetector.swift */; };
|
| 102 |
+ A1B2C3D4E5F6A7B8C9D0E111 /* DeviceProfilesCatalog.json in Resources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E110 /* DeviceProfilesCatalog.json */; };
|
|
| 98 | 103 |
/* End PBXBuildFile section */ |
| 99 | 104 |
|
| 100 | 105 |
/* Begin PBXFileReference section */ |
@@ -206,6 +211,10 @@ |
||
| 206 | 211 |
CD0001053FA0000000000005 /* SidebarChargedDeviceLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarChargedDeviceLibraryView.swift; sourceTree = "<group>"; };
|
| 207 | 212 |
CD0001063FA0000000000006 /* ChargedDeviceDetailTabBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceDetailTabBarView.swift; sourceTree = "<group>"; };
|
| 208 | 213 |
CE00CE023C8E4A7A00CE00CE /* ChargerEditorSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargerEditorSheetView.swift; sourceTree = "<group>"; };
|
| 214 |
+ A1B2C3D4E5F6A7B8C9D0E201 /* PowerbankEditorSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerbankEditorSheetView.swift; sourceTree = "<group>"; };
|
|
| 215 |
+ A1B2C3D4E5F6A7B8C9D0E202 /* PowerbankDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerbankDetailView.swift; sourceTree = "<group>"; };
|
|
| 216 |
+ A1B2C3D4E5F6A7B8C9D0E203 /* PowerbankSidebarCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerbankSidebarCardView.swift; sourceTree = "<group>"; };
|
|
| 217 |
+ A1B2C3D4E5F6A7B8C9D0E204 /* SidebarPowerbanksSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarPowerbanksSectionView.swift; sourceTree = "<group>"; };
|
|
| 209 | 218 |
D28F11023C8E4A7A00A10012 /* MeterHomeTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterHomeTabView.swift; sourceTree = "<group>"; };
|
| 210 | 219 |
D28F11043C8E4A7A00A10014 /* MeterLiveTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterLiveTabView.swift; sourceTree = "<group>"; };
|
| 211 | 220 |
D28F11063C8E4A7A00A10016 /* MeterChartTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterChartTabView.swift; sourceTree = "<group>"; };
|
@@ -230,6 +239,9 @@ |
||
| 230 | 239 |
F10000026F5D4C95B6487F17 /* USB_Meter 16.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 16.xcdatamodel"; sourceTree = "<group>"; };
|
| 231 | 240 |
F10000036F5D4C95B6487F18 /* USB_Meter 17.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 17.xcdatamodel"; sourceTree = "<group>"; };
|
| 232 | 241 |
F10000046F5D4C95B6487F19 /* USB_Meter 18.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 18.xcdatamodel"; sourceTree = "<group>"; };
|
| 242 |
+ A1B2C3D4E5F6A7B8C9D0E101 /* USB_Meter 19.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 19.xcdatamodel"; sourceTree = "<group>"; };
|
|
| 243 |
+ A1B2C3D4E5F6A7B8C9D0E102 /* USB_Meter 20.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 20.xcdatamodel"; sourceTree = "<group>"; };
|
|
| 244 |
+ A1B2C3D4E5F6A7B8C9D0E110 /* DeviceProfilesCatalog.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = DeviceProfilesCatalog.json; sourceTree = "<group>"; };
|
|
| 233 | 245 |
F20000016F5D4C95B6487F16 /* ChargingWindowDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargingWindowDetector.swift; sourceTree = "<group>"; };
|
| 234 | 246 |
/* End PBXFileReference section */ |
| 235 | 247 |
|
@@ -400,6 +412,7 @@ |
||
| 400 | 412 |
children = ( |
| 401 | 413 |
4383B464240EB6B200DAAEBF /* UserDefault.swift */, |
| 402 | 414 |
C1F000023C90000100A10002 /* ChargedDeviceTemplates.json */, |
| 415 |
+ A1B2C3D4E5F6A7B8C9D0E110 /* DeviceProfilesCatalog.json */, |
|
| 403 | 416 |
); |
| 404 | 417 |
path = Templates; |
| 405 | 418 |
sourceTree = "<group>"; |
@@ -489,6 +502,7 @@ |
||
| 489 | 502 |
56BA4CE53B6B2C4EBA42C81A /* MeterMappingDebugView.swift */, |
| 490 | 503 |
437D47CF2415F8CF00B7768E /* Meter */, |
| 491 | 504 |
F1F1F1F1F1F1F1F1F1F1F1F1 /* Chargers */, |
| 505 |
+ A1B2C3D4E5F6A7B8C9D0E2FF /* Powerbanks */, |
|
| 492 | 506 |
D28F10023C8E4A7A00A10002 /* Components */, |
| 493 | 507 |
4311E639241384960080EA59 /* DeviceHelpView.swift */, |
| 494 | 508 |
); |
@@ -519,10 +533,21 @@ |
||
| 519 | 533 |
CD0001033FA0000000000003 /* ChargedDeviceSidebarCardView.swift */, |
| 520 | 534 |
CD0001053FA0000000000005 /* SidebarChargedDeviceLibraryView.swift */, |
| 521 | 535 |
C10000173C8E4A7A00A10017 /* SidebarChargedDevicesSectionView.swift */, |
| 536 |
+ A1B2C3D4E5F6A7B8C9D0E203 /* PowerbankSidebarCardView.swift */, |
|
| 537 |
+ A1B2C3D4E5F6A7B8C9D0E204 /* SidebarPowerbanksSectionView.swift */, |
|
| 522 | 538 |
); |
| 523 | 539 |
path = Sidebar; |
| 524 | 540 |
sourceTree = "<group>"; |
| 525 | 541 |
}; |
| 542 |
+ A1B2C3D4E5F6A7B8C9D0E2FF /* Powerbanks */ = {
|
|
| 543 |
+ isa = PBXGroup; |
|
| 544 |
+ children = ( |
|
| 545 |
+ A1B2C3D4E5F6A7B8C9D0E201 /* PowerbankEditorSheetView.swift */, |
|
| 546 |
+ A1B2C3D4E5F6A7B8C9D0E202 /* PowerbankDetailView.swift */, |
|
| 547 |
+ ); |
|
| 548 |
+ path = Powerbanks; |
|
| 549 |
+ sourceTree = "<group>"; |
|
| 550 |
+ }; |
|
| 526 | 551 |
C10000203C8E4A7A00A10020 /* ChargedDevices */ = {
|
| 527 | 552 |
isa = PBXGroup; |
| 528 | 553 |
children = ( |
@@ -849,6 +874,7 @@ |
||
| 849 | 874 |
43CBF66C240BF3ED00255B8B /* Preview Assets.xcassets in Resources */, |
| 850 | 875 |
43CBF669240BF3ED00255B8B /* Assets.xcassets in Resources */, |
| 851 | 876 |
C1F000013C90000100A10001 /* ChargedDeviceTemplates.json in Resources */, |
| 877 |
+ A1B2C3D4E5F6A7B8C9D0E111 /* DeviceProfilesCatalog.json in Resources */, |
|
| 852 | 878 |
); |
| 853 | 879 |
runOnlyForDeploymentPostprocessing = 0; |
| 854 | 880 |
}; |
@@ -897,6 +923,10 @@ |
||
| 897 | 923 |
C10000073C8E4A7A00A10007 /* SidebarChargedDevicesSectionView.swift in Sources */, |
| 898 | 924 |
C10000083C8E4A7A00A10008 /* BatteryCheckpointEditorSheetView.swift in Sources */, |
| 899 | 925 |
CE00CE013C8E4A7A00CE00CE /* ChargerEditorSheetView.swift in Sources */, |
| 926 |
+ A1B2C3D4E5F6A7B8C9D0E211 /* PowerbankEditorSheetView.swift in Sources */, |
|
| 927 |
+ A1B2C3D4E5F6A7B8C9D0E212 /* PowerbankDetailView.swift in Sources */, |
|
| 928 |
+ A1B2C3D4E5F6A7B8C9D0E213 /* PowerbankSidebarCardView.swift in Sources */, |
|
| 929 |
+ A1B2C3D4E5F6A7B8C9D0E214 /* SidebarPowerbanksSectionView.swift in Sources */, |
|
| 900 | 930 |
CD0002013FA0000000000001 /* ChargedDeviceIdentityViews.swift in Sources */, |
| 901 | 931 |
CD0002023FA0000000000002 /* ChargedDeviceLibraryRowView.swift in Sources */, |
| 902 | 932 |
CD0002033FA0000000000003 /* ChargedDeviceSidebarCardView.swift in Sources */, |
@@ -1198,8 +1228,10 @@ |
||
| 1198 | 1228 |
F10000026F5D4C95B6487F17 /* USB_Meter 16.xcdatamodel */, |
| 1199 | 1229 |
F10000036F5D4C95B6487F18 /* USB_Meter 17.xcdatamodel */, |
| 1200 | 1230 |
F10000046F5D4C95B6487F19 /* USB_Meter 18.xcdatamodel */, |
| 1231 |
+ A1B2C3D4E5F6A7B8C9D0E101 /* USB_Meter 19.xcdatamodel */, |
|
| 1232 |
+ A1B2C3D4E5F6A7B8C9D0E102 /* USB_Meter 20.xcdatamodel */, |
|
| 1201 | 1233 |
); |
| 1202 |
- currentVersion = F10000046F5D4C95B6487F19 /* USB_Meter 18.xcdatamodel */; |
|
| 1234 |
+ currentVersion = A1B2C3D4E5F6A7B8C9D0E102 /* USB_Meter 20.xcdatamodel */; |
|
| 1203 | 1235 |
path = CKModel.xcdatamodeld; |
| 1204 | 1236 |
sourceTree = "<group>"; |
| 1205 | 1237 |
versionGroupType = wrapper.xcdatamodel; |
@@ -94,6 +94,7 @@ final class AppData : ObservableObject {
|
||
| 94 | 94 |
} |
| 95 | 95 |
} |
| 96 | 96 |
@Published private(set) var chargedDevices: [ChargedDeviceSummary] = [] |
| 97 |
+ @Published private(set) var powerbanks: [PowerbankSummary] = [] |
|
| 97 | 98 |
@Published private(set) var activeChargerStandbySessions: [String: ChargerStandbyPowerMonitorSession] = [:] |
| 98 | 99 |
|
| 99 | 100 |
var deviceSummaries: [ChargedDeviceSummary] {
|
@@ -104,6 +105,10 @@ final class AppData : ObservableObject {
|
||
| 104 | 105 |
chargedDevices.filter { $0.isCharger }
|
| 105 | 106 |
} |
| 106 | 107 |
|
| 108 |
+ var powerbankSummaries: [PowerbankSummary] {
|
|
| 109 |
+ powerbanks |
|
| 110 |
+ } |
|
| 111 |
+ |
|
| 107 | 112 |
var cloudAvailability: MeterNameStore.CloudAvailability {
|
| 108 | 113 |
meterStore.currentCloudAvailability |
| 109 | 114 |
} |
@@ -165,9 +170,39 @@ final class AppData : ObservableObject {
|
||
| 165 | 170 |
} |
| 166 | 171 |
|
| 167 | 172 |
chargeNotificationCoordinator.ensureAuthorizationIfNeeded() |
| 173 |
+ seedDeviceProfilesCatalogIfNeeded() |
|
| 174 |
+ migrateDeviceProfilesIfNeeded() |
|
| 168 | 175 |
reloadChargedDevices() |
| 169 | 176 |
} |
| 170 | 177 |
|
| 178 |
+ private static let cloudProfileSeedVersionKey = "cloudProfileSeedVersion" |
|
| 179 |
+ private static let currentCloudProfileSeedVersion: Int = 1 |
|
| 180 |
+ private static let cloudDeviceProfileMigrationVersionKey = "cloudDeviceProfileMigrationVersion" |
|
| 181 |
+ private static let currentCloudDeviceProfileMigrationVersion: Int = 1 |
|
| 182 |
+ |
|
| 183 |
+ private func seedDeviceProfilesCatalogIfNeeded() {
|
|
| 184 |
+ let defaults = UserDefaults.standard |
|
| 185 |
+ let installed = defaults.integer(forKey: AppData.cloudProfileSeedVersionKey) |
|
| 186 |
+ guard installed < AppData.currentCloudProfileSeedVersion else { return }
|
|
| 187 |
+ |
|
| 188 |
+ let catalog = DeviceProfileCatalog.shared.profiles |
|
| 189 |
+ guard catalog.isEmpty == false else { return }
|
|
| 190 |
+ |
|
| 191 |
+ if chargeInsightsStore?.seedDeviceProfilesCatalog(catalog) == true {
|
|
| 192 |
+ defaults.set(AppData.currentCloudProfileSeedVersion, forKey: AppData.cloudProfileSeedVersionKey) |
|
| 193 |
+ } |
|
| 194 |
+ } |
|
| 195 |
+ |
|
| 196 |
+ private func migrateDeviceProfilesIfNeeded() {
|
|
| 197 |
+ let defaults = UserDefaults.standard |
|
| 198 |
+ let installed = defaults.integer(forKey: AppData.cloudDeviceProfileMigrationVersionKey) |
|
| 199 |
+ guard installed < AppData.currentCloudDeviceProfileMigrationVersion else { return }
|
|
| 200 |
+ |
|
| 201 |
+ if chargeInsightsStore?.migrateDevicesToProfiles() == true {
|
|
| 202 |
+ defaults.set(AppData.currentCloudDeviceProfileMigrationVersion, forKey: AppData.cloudDeviceProfileMigrationVersionKey) |
|
| 203 |
+ } |
|
| 204 |
+ } |
|
| 205 |
+ |
|
| 171 | 206 |
func meterName(for macAddress: String) -> String? {
|
| 172 | 207 |
meterStore.name(for: macAddress) |
| 173 | 208 |
} |
@@ -343,6 +378,8 @@ final class AppData : ObservableObject {
|
||
| 343 | 378 |
name: String, |
| 344 | 379 |
deviceClass: ChargedDeviceClass, |
| 345 | 380 |
templateID: String?, |
| 381 |
+ profileID: String? = nil, |
|
| 382 |
+ hasInternalSubject: Bool = false, |
|
| 346 | 383 |
chargingStateAvailability: ChargingStateAvailability, |
| 347 | 384 |
supportsWiredCharging: Bool, |
| 348 | 385 |
supportsWirelessCharging: Bool, |
@@ -354,6 +391,8 @@ final class AppData : ObservableObject {
|
||
| 354 | 391 |
name: name, |
| 355 | 392 |
deviceClass: deviceClass, |
| 356 | 393 |
templateID: templateID, |
| 394 |
+ profileID: profileID, |
|
| 395 |
+ hasInternalSubject: hasInternalSubject, |
|
| 357 | 396 |
chargingStateAvailability: chargingStateAvailability, |
| 358 | 397 |
supportsWiredCharging: supportsWiredCharging, |
| 359 | 398 |
supportsWirelessCharging: supportsWirelessCharging, |
@@ -388,12 +427,69 @@ final class AppData : ObservableObject {
|
||
| 388 | 427 |
return didSave |
| 389 | 428 |
} |
| 390 | 429 |
|
| 430 |
+ @discardableResult |
|
| 431 |
+ func createPowerbank( |
|
| 432 |
+ name: String, |
|
| 433 |
+ templateID: String?, |
|
| 434 |
+ batteryLevelReporting: BatteryLevelReporting, |
|
| 435 |
+ batteryBarsCount: Int, |
|
| 436 |
+ notes: String? |
|
| 437 |
+ ) -> Bool {
|
|
| 438 |
+ let didSave = chargeInsightsStore?.createPowerbank( |
|
| 439 |
+ name: name, |
|
| 440 |
+ templateID: templateID, |
|
| 441 |
+ batteryLevelReporting: batteryLevelReporting, |
|
| 442 |
+ batteryBarsCount: batteryBarsCount, |
|
| 443 |
+ notes: notes |
|
| 444 |
+ ) ?? false |
|
| 445 |
+ |
|
| 446 |
+ if didSave {
|
|
| 447 |
+ reloadChargedDevices() |
|
| 448 |
+ } |
|
| 449 |
+ return didSave |
|
| 450 |
+ } |
|
| 451 |
+ |
|
| 452 |
+ @discardableResult |
|
| 453 |
+ func updatePowerbank( |
|
| 454 |
+ id: UUID, |
|
| 455 |
+ name: String, |
|
| 456 |
+ templateID: String?, |
|
| 457 |
+ batteryLevelReporting: BatteryLevelReporting, |
|
| 458 |
+ batteryBarsCount: Int, |
|
| 459 |
+ notes: String? |
|
| 460 |
+ ) -> Bool {
|
|
| 461 |
+ let didSave = chargeInsightsStore?.updatePowerbank( |
|
| 462 |
+ id: id, |
|
| 463 |
+ name: name, |
|
| 464 |
+ templateID: templateID, |
|
| 465 |
+ batteryLevelReporting: batteryLevelReporting, |
|
| 466 |
+ batteryBarsCount: batteryBarsCount, |
|
| 467 |
+ notes: notes |
|
| 468 |
+ ) ?? false |
|
| 469 |
+ |
|
| 470 |
+ if didSave {
|
|
| 471 |
+ reloadChargedDevices() |
|
| 472 |
+ } |
|
| 473 |
+ return didSave |
|
| 474 |
+ } |
|
| 475 |
+ |
|
| 476 |
+ @discardableResult |
|
| 477 |
+ func deletePowerbank(id: UUID) -> Bool {
|
|
| 478 |
+ let didSave = chargeInsightsStore?.deletePowerbank(id: id) ?? false |
|
| 479 |
+ if didSave {
|
|
| 480 |
+ reloadChargedDevices() |
|
| 481 |
+ } |
|
| 482 |
+ return didSave |
|
| 483 |
+ } |
|
| 484 |
+ |
|
| 391 | 485 |
@discardableResult |
| 392 | 486 |
func updateDevice( |
| 393 | 487 |
id: UUID, |
| 394 | 488 |
name: String, |
| 395 | 489 |
deviceClass: ChargedDeviceClass, |
| 396 | 490 |
templateID: String?, |
| 491 |
+ profileID: String? = nil, |
|
| 492 |
+ hasInternalSubject: Bool = false, |
|
| 397 | 493 |
chargingStateAvailability: ChargingStateAvailability, |
| 398 | 494 |
supportsWiredCharging: Bool, |
| 399 | 495 |
supportsWirelessCharging: Bool, |
@@ -406,6 +502,8 @@ final class AppData : ObservableObject {
|
||
| 406 | 502 |
name: name, |
| 407 | 503 |
deviceClass: deviceClass, |
| 408 | 504 |
templateID: templateID, |
| 505 |
+ profileID: profileID, |
|
| 506 |
+ hasInternalSubject: hasInternalSubject, |
|
| 409 | 507 |
chargingStateAvailability: chargingStateAvailability, |
| 410 | 508 |
supportsWiredCharging: supportsWiredCharging, |
| 411 | 509 |
supportsWirelessCharging: supportsWirelessCharging, |
@@ -457,6 +555,7 @@ final class AppData : ObservableObject {
|
||
| 457 | 555 |
for meter: Meter, |
| 458 | 556 |
chargedDeviceID: UUID, |
| 459 | 557 |
chargerID: UUID?, |
| 558 |
+ sourcePowerbankID: UUID? = nil, |
|
| 460 | 559 |
chargingTransportMode: ChargingTransportMode, |
| 461 | 560 |
chargingStateMode: ChargingStateMode, |
| 462 | 561 |
autoStopEnabled: Bool, |
@@ -473,6 +572,7 @@ final class AppData : ObservableObject {
|
||
| 473 | 572 |
for: snapshot, |
| 474 | 573 |
chargedDeviceID: chargedDeviceID, |
| 475 | 574 |
chargerID: chargerID, |
| 575 |
+ sourcePowerbankID: sourcePowerbankID, |
|
| 476 | 576 |
chargingTransportMode: chargingTransportMode, |
| 477 | 577 |
chargingStateMode: chargingStateMode, |
| 478 | 578 |
autoStopEnabled: autoStopEnabled, |
@@ -590,7 +690,9 @@ final class AppData : ObservableObject {
|
||
| 590 | 690 |
func addBatteryCheckpoint( |
| 591 | 691 |
percent: Double, |
| 592 | 692 |
for sessionID: UUID, |
| 593 |
- measuredEnergyWh: Double? |
|
| 693 |
+ measuredEnergyWh: Double?, |
|
| 694 |
+ subject: CheckpointSubject = .chargedDevice, |
|
| 695 |
+ barsValue: Int = 0 |
|
| 594 | 696 |
) -> Bool {
|
| 595 | 697 |
guard canAddBatteryCheckpoint(to: sessionID) else {
|
| 596 | 698 |
return false |
@@ -599,7 +701,9 @@ final class AppData : ObservableObject {
|
||
| 599 | 701 |
let didSave = chargeInsightsStore?.addBatteryCheckpoint( |
| 600 | 702 |
percent: percent, |
| 601 | 703 |
for: sessionID, |
| 602 |
- measuredEnergyWh: measuredEnergyWh |
|
| 704 |
+ measuredEnergyWh: measuredEnergyWh, |
|
| 705 |
+ subject: subject, |
|
| 706 |
+ barsValue: barsValue |
|
| 603 | 707 |
) ?? false |
| 604 | 708 |
|
| 605 | 709 |
if didSave {
|
@@ -1016,11 +1120,13 @@ final class AppData : ObservableObject {
|
||
| 1016 | 1120 |
standbyMeasurementsByChargerID[chargedDevice.id] ?? [] |
| 1017 | 1121 |
) |
| 1018 | 1122 |
} |
| 1123 |
+ let powerbankSummaries = readStore?.fetchPowerbankSummaries() ?? [] |
|
| 1019 | 1124 |
|
| 1020 | 1125 |
DispatchQueue.main.async { [weak self] in
|
| 1021 | 1126 |
guard let self else { return }
|
| 1022 | 1127 |
|
| 1023 | 1128 |
self.chargedDevices = summaries |
| 1129 |
+ self.powerbanks = powerbankSummaries |
|
| 1024 | 1130 |
self.chargeNotificationCoordinator.process(chargedDevices: self.deviceSummaries) |
| 1025 | 1131 |
for meter in self.meters.values {
|
| 1026 | 1132 |
self.restoreChargeMonitoringStateIfNeeded(for: meter) |
@@ -3,6 +3,6 @@ |
||
| 3 | 3 |
<plist version="1.0"> |
| 4 | 4 |
<dict> |
| 5 | 5 |
<key>_XCCurrentVersionName</key> |
| 6 |
- <string>USB_Meter 18.xcdatamodel</string> |
|
| 6 |
+ <string>USB_Meter 20.xcdatamodel</string> |
|
| 7 | 7 |
</dict> |
| 8 | 8 |
</plist> |
@@ -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,10 +537,290 @@ 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 |
@@ -551,6 +831,10 @@ struct ChargeCheckpointSummary: Identifiable, Hashable {
|
||
| 551 | 831 |
var flag: ChargeCheckpointFlag {
|
| 552 | 832 |
ChargeCheckpointFlag.fromStoredLabel(label) |
| 553 | 833 |
} |
| 834 |
+ |
|
| 835 |
+ var subject: CheckpointSubject {
|
|
| 836 |
+ powerbankID == nil ? .chargedDevice : .powerbank |
|
| 837 |
+ } |
|
| 554 | 838 |
} |
| 555 | 839 |
|
| 556 | 840 |
enum ChargeCheckpointFlag: String, CaseIterable {
|
@@ -618,7 +902,9 @@ struct ChargeSessionSampleSummary: Identifiable, Hashable {
|
||
| 618 | 902 |
struct ChargeSessionSummary: Identifiable, Hashable {
|
| 619 | 903 |
let id: UUID |
| 620 | 904 |
let chargedDeviceID: UUID |
| 905 |
+ let chargedPowerbankID: UUID? |
|
| 621 | 906 |
let chargerID: UUID? |
| 907 |
+ let sourcePowerbankID: UUID? |
|
| 622 | 908 |
let meterMACAddress: String? |
| 623 | 909 |
let meterName: String? |
| 624 | 910 |
let meterModel: String? |
@@ -685,6 +971,21 @@ struct ChargeSessionSummary: Identifiable, Hashable {
|
||
| 685 | 971 |
) |
| 686 | 972 |
} |
| 687 | 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 |
+ |
|
| 688 | 989 |
var duration: TimeInterval {
|
| 689 | 990 |
(endedAt ?? lastObservedAt).timeIntervalSince(startedAt) |
| 690 | 991 |
} |
@@ -1280,6 +1581,8 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
|
||
| 1280 | 1581 |
let deviceClass: ChargedDeviceClass |
| 1281 | 1582 |
let deviceTemplateID: String? |
| 1282 | 1583 |
let templateDefinition: ChargedDeviceTemplateDefinition? |
| 1584 |
+ let profileID: String? |
|
| 1585 |
+ let hasInternalSubject: Bool |
|
| 1283 | 1586 |
let supportsChargingWhileOff: Bool |
| 1284 | 1587 |
let chargingStateAvailability: ChargingStateAvailability |
| 1285 | 1588 |
let supportsWiredCharging: Bool |
@@ -1313,6 +1616,17 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
|
||
| 1313 | 1616 |
deviceClass == .charger |
| 1314 | 1617 |
} |
| 1315 | 1618 |
|
| 1619 |
+ /// True when the device's active catalog profile is one of the case-style |
|
| 1620 |
+ /// profiles (AirPods case, charging case, …) — i.e. the editor exposes the |
|
| 1621 |
+ /// `hasInternalSubject` toggle and the detail UI should surface its state. |
|
| 1622 |
+ var supportsInternalSubject: Bool {
|
|
| 1623 |
+ guard let profileID, |
|
| 1624 |
+ let profile = DeviceProfileCatalog.shared.profile(id: profileID) else {
|
|
| 1625 |
+ return false |
|
| 1626 |
+ } |
|
| 1627 |
+ return profile.capHasInternalSubject |
|
| 1628 |
+ } |
|
| 1629 |
+ |
|
| 1316 | 1630 |
var kind: ChargedDeviceKind {
|
| 1317 | 1631 |
deviceClass.kind |
| 1318 | 1632 |
} |
@@ -1640,6 +1954,8 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
|
||
| 1640 | 1954 |
deviceClass: deviceClass, |
| 1641 | 1955 |
deviceTemplateID: deviceTemplateID, |
| 1642 | 1956 |
templateDefinition: templateDefinition, |
| 1957 |
+ profileID: profileID, |
|
| 1958 |
+ hasInternalSubject: hasInternalSubject, |
|
| 1643 | 1959 |
supportsChargingWhileOff: supportsChargingWhileOff, |
| 1644 | 1960 |
chargingStateAvailability: chargingStateAvailability, |
| 1645 | 1961 |
supportsWiredCharging: supportsWiredCharging, |
@@ -1686,3 +2002,126 @@ struct ChargingMonitorSnapshot {
|
||
| 1686 | 2002 |
let meterRecordingDurationSeconds: TimeInterval? |
| 1687 | 2003 |
let fallbackStopThresholdAmps: Double |
| 1688 | 2004 |
} |
| 2005 |
+ |
|
| 2006 |
+// MARK: - Powerbank |
|
| 2007 |
+ |
|
| 2008 |
+enum BatteryLevelReporting: String, CaseIterable, Identifiable, Codable {
|
|
| 2009 |
+ case percent |
|
| 2010 |
+ case bars |
|
| 2011 |
+ case fullOnly |
|
| 2012 |
+ case none |
|
| 2013 |
+ |
|
| 2014 |
+ var id: String { rawValue }
|
|
| 2015 |
+ |
|
| 2016 |
+ var title: String {
|
|
| 2017 |
+ switch self {
|
|
| 2018 |
+ case .percent: return "Percent" |
|
| 2019 |
+ case .bars: return "Bars" |
|
| 2020 |
+ case .fullOnly: return "Full only" |
|
| 2021 |
+ case .none: return "Not reported" |
|
| 2022 |
+ } |
|
| 2023 |
+ } |
|
| 2024 |
+ |
|
| 2025 |
+ var description: String {
|
|
| 2026 |
+ switch self {
|
|
| 2027 |
+ case .percent: |
|
| 2028 |
+ return "The powerbank reports battery level as 0–100%." |
|
| 2029 |
+ case .bars: |
|
| 2030 |
+ return "The powerbank reports battery level as discrete bars (e.g. 4 of 4)." |
|
| 2031 |
+ case .fullOnly: |
|
| 2032 |
+ return "The powerbank has a single LED that lights only when charging completes — there is no signal for any partial level." |
|
| 2033 |
+ case .none: |
|
| 2034 |
+ return "The powerbank does not report a battery level." |
|
| 2035 |
+ } |
|
| 2036 |
+ } |
|
| 2037 |
+ |
|
| 2038 |
+ var allowsCheckpoints: Bool {
|
|
| 2039 |
+ self != .none |
|
| 2040 |
+ } |
|
| 2041 |
+} |
|
| 2042 |
+ |
|
| 2043 |
+enum CheckpointSubject: String, Codable, Hashable {
|
|
| 2044 |
+ case chargedDevice |
|
| 2045 |
+ case powerbank |
|
| 2046 |
+ |
|
| 2047 |
+ var title: String {
|
|
| 2048 |
+ switch self {
|
|
| 2049 |
+ case .chargedDevice: return "Device" |
|
| 2050 |
+ case .powerbank: return "Powerbank" |
|
| 2051 |
+ } |
|
| 2052 |
+ } |
|
| 2053 |
+} |
|
| 2054 |
+ |
|
| 2055 |
+enum ChargeSessionSource: Hashable {
|
|
| 2056 |
+ case none |
|
| 2057 |
+ case charger(UUID) |
|
| 2058 |
+ case powerbank(UUID) |
|
| 2059 |
+ |
|
| 2060 |
+ var chargerID: UUID? {
|
|
| 2061 |
+ if case .charger(let id) = self { return id }
|
|
| 2062 |
+ return nil |
|
| 2063 |
+ } |
|
| 2064 |
+ |
|
| 2065 |
+ var powerbankID: UUID? {
|
|
| 2066 |
+ if case .powerbank(let id) = self { return id }
|
|
| 2067 |
+ return nil |
|
| 2068 |
+ } |
|
| 2069 |
+ |
|
| 2070 |
+ var isTracked: Bool {
|
|
| 2071 |
+ if case .none = self { return false }
|
|
| 2072 |
+ return true |
|
| 2073 |
+ } |
|
| 2074 |
+} |
|
| 2075 |
+ |
|
| 2076 |
+struct PowerbankSummary: Identifiable, Hashable {
|
|
| 2077 |
+ let id: UUID |
|
| 2078 |
+ let qrIdentifier: String |
|
| 2079 |
+ let name: String |
|
| 2080 |
+ let deviceTemplateID: String? |
|
| 2081 |
+ let templateDefinition: ChargedDeviceTemplateDefinition? |
|
| 2082 |
+ let batteryLevelReporting: BatteryLevelReporting |
|
| 2083 |
+ let batteryBarsCount: Int |
|
| 2084 |
+ let estimatedBatteryCapacityWh: Double? |
|
| 2085 |
+ let apparentCapacityWh: Double? |
|
| 2086 |
+ let configuredCompletionCurrentAmps: Double? |
|
| 2087 |
+ let learnedCompletionCurrentAmps: Double? |
|
| 2088 |
+ let minimumCurrentAmps: Double? |
|
| 2089 |
+ let sourceObservedVoltageSelections: [Double] |
|
| 2090 |
+ let sourceVoltageMaxCurrents: [Double: Double] |
|
| 2091 |
+ let sourceIdleCurrentAmps: Double? |
|
| 2092 |
+ let sourceMaximumPowerWatts: Double? |
|
| 2093 |
+ let sourceEfficiencyFactor: Double? |
|
| 2094 |
+ let notes: String? |
|
| 2095 |
+ let createdAt: Date |
|
| 2096 |
+ let updatedAt: Date |
|
| 2097 |
+ let sessionsAsSubject: [ChargeSessionSummary] |
|
| 2098 |
+ let sessionsAsSource: [ChargeSessionSummary] |
|
| 2099 |
+ |
|
| 2100 |
+ var fallbackIdentitySymbolName: String { "battery.100.bolt" }
|
|
| 2101 |
+ |
|
| 2102 |
+ var identityIcon: ChargedDeviceTemplateIcon {
|
|
| 2103 |
+ templateDefinition?.icon ?? .systemSymbol(fallbackIdentitySymbolName) |
|
| 2104 |
+ } |
|
| 2105 |
+ |
|
| 2106 |
+ var identitySymbolName: String {
|
|
| 2107 |
+ identityIcon.resolvedSystemSymbolName(fallbackSystemName: fallbackIdentitySymbolName) |
|
| 2108 |
+ } |
|
| 2109 |
+ |
|
| 2110 |
+ var identityTitle: String {
|
|
| 2111 |
+ templateDefinition?.name ?? "Powerbank" |
|
| 2112 |
+ } |
|
| 2113 |
+ |
|
| 2114 |
+ /// Open session in which this powerbank participates as either subject or source. |
|
| 2115 |
+ var openSession: ChargeSessionSummary? {
|
|
| 2116 |
+ sessionsAsSubject.first(where: \.isOpen) |
|
| 2117 |
+ ?? sessionsAsSource.first(where: \.isOpen) |
|
| 2118 |
+ } |
|
| 2119 |
+ |
|
| 2120 |
+ var totalDeliveredEnergyWh: Double {
|
|
| 2121 |
+ sessionsAsSource.reduce(0) { $0 + $1.measuredEnergyWh }
|
|
| 2122 |
+ } |
|
| 2123 |
+ |
|
| 2124 |
+ var totalReceivedEnergyWh: Double {
|
|
| 2125 |
+ sessionsAsSubject.reduce(0) { $0 + $1.measuredEnergyWh }
|
|
| 2126 |
+ } |
|
| 2127 |
+} |
|
@@ -11,9 +11,11 @@ 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" |
| 18 |
+ static let deviceProfile = "DeviceProfile" |
|
| 17 | 19 |
} |
| 18 | 20 |
|
| 19 | 21 |
private static let maximumChargeSessionDuration: TimeInterval = 12 * 60 * 60 |
@@ -51,6 +53,223 @@ final class ChargeInsightsStore {
|
||
| 51 | 53 |
} |
| 52 | 54 |
} |
| 53 | 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 |
+ |
|
| 54 | 273 |
@discardableResult |
| 55 | 274 |
func flushPendingChanges() -> Bool {
|
| 56 | 275 |
var didSave = false |
@@ -171,6 +390,8 @@ final class ChargeInsightsStore {
|
||
| 171 | 390 |
name: String, |
| 172 | 391 |
deviceClass: ChargedDeviceClass, |
| 173 | 392 |
templateID: String?, |
| 393 |
+ profileID: String? = nil, |
|
| 394 |
+ hasInternalSubject: Bool = false, |
|
| 174 | 395 |
chargingStateAvailability: ChargingStateAvailability, |
| 175 | 396 |
supportsWiredCharging: Bool, |
| 176 | 397 |
supportsWirelessCharging: Bool, |
@@ -186,6 +407,7 @@ final class ChargeInsightsStore {
|
||
| 186 | 407 |
supportsWirelessCharging: supportsWirelessCharging |
| 187 | 408 |
) |
| 188 | 409 |
let normalizedTemplateID = normalizedTemplateID(templateID, kind: .device) |
| 410 |
+ let normalizedProfileID = normalizedOptionalText(profileID) |
|
| 189 | 411 |
guard !normalizedName.isEmpty else { return false }
|
| 190 | 412 |
guard normalizedChargingSupport.wired || normalizedChargingSupport.wireless else { return false }
|
| 191 | 413 |
|
@@ -201,6 +423,8 @@ final class ChargeInsightsStore {
|
||
| 201 | 423 |
object.setValue(normalizedName, forKey: "name") |
| 202 | 424 |
object.setValue(deviceClass.rawValue, forKey: "deviceClassRawValue") |
| 203 | 425 |
object.setValue(normalizedTemplateID, forKey: "deviceTemplateID") |
| 426 |
+ object.setValue(normalizedProfileID, forKey: "profileID") |
|
| 427 |
+ object.setValue(hasInternalSubject, forKey: "hasInternalSubject") |
|
| 204 | 428 |
object.setValue(normalizedChargingStateAvailability.rawValue, forKey: "chargingStateAvailabilityRawValue") |
| 205 | 429 |
object.setValue(normalizedChargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff") |
| 206 | 430 |
object.setValue(normalizedChargingSupport.wired, forKey: "supportsWiredCharging") |
@@ -219,6 +443,100 @@ final class ChargeInsightsStore {
|
||
| 219 | 443 |
} |
| 220 | 444 |
|
| 221 | 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 |
+ |
|
| 222 | 540 |
func createCharger( |
| 223 | 541 |
name: String, |
| 224 | 542 |
chargerType: ChargerType, |
@@ -265,6 +583,8 @@ final class ChargeInsightsStore {
|
||
| 265 | 583 |
name: String, |
| 266 | 584 |
deviceClass: ChargedDeviceClass, |
| 267 | 585 |
templateID: String?, |
| 586 |
+ profileID: String? = nil, |
|
| 587 |
+ hasInternalSubject: Bool = false, |
|
| 268 | 588 |
chargingStateAvailability: ChargingStateAvailability, |
| 269 | 589 |
supportsWiredCharging: Bool, |
| 270 | 590 |
supportsWirelessCharging: Bool, |
@@ -280,6 +600,7 @@ final class ChargeInsightsStore {
|
||
| 280 | 600 |
supportsWirelessCharging: supportsWirelessCharging |
| 281 | 601 |
) |
| 282 | 602 |
let normalizedTemplateID = normalizedTemplateID(templateID, kind: .device) |
| 603 |
+ let normalizedProfileID = normalizedOptionalText(profileID) |
|
| 283 | 604 |
guard !normalizedName.isEmpty else { return false }
|
| 284 | 605 |
guard normalizedChargingSupport.wired || normalizedChargingSupport.wireless else { return false }
|
| 285 | 606 |
|
@@ -301,6 +622,8 @@ final class ChargeInsightsStore {
|
||
| 301 | 622 |
object.setValue(normalizedName, forKey: "name") |
| 302 | 623 |
object.setValue(deviceClass.rawValue, forKey: "deviceClassRawValue") |
| 303 | 624 |
object.setValue(normalizedTemplateID, forKey: "deviceTemplateID") |
| 625 |
+ object.setValue(normalizedProfileID, forKey: "profileID") |
|
| 626 |
+ object.setValue(hasInternalSubject, forKey: "hasInternalSubject") |
|
| 304 | 627 |
object.setValue(normalizedChargingStateAvailability.rawValue, forKey: "chargingStateAvailabilityRawValue") |
| 305 | 628 |
object.setValue(normalizedChargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff") |
| 306 | 629 |
object.setValue(normalizedChargingSupport.wired, forKey: "supportsWiredCharging") |
@@ -412,6 +735,7 @@ final class ChargeInsightsStore {
|
||
| 412 | 735 |
for snapshot: ChargingMonitorSnapshot, |
| 413 | 736 |
chargedDeviceID: UUID, |
| 414 | 737 |
chargerID: UUID?, |
| 738 |
+ sourcePowerbankID: UUID? = nil, |
|
| 415 | 739 |
chargingTransportMode: ChargingTransportMode, |
| 416 | 740 |
chargingStateMode: ChargingStateMode, |
| 417 | 741 |
autoStopEnabled: Bool, |
@@ -451,7 +775,9 @@ final class ChargeInsightsStore {
|
||
| 451 | 775 |
if let charger, isChargerObject(charger) == false {
|
| 452 | 776 |
return |
| 453 | 777 |
} |
| 454 |
- 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 {
|
|
| 455 | 781 |
return |
| 456 | 782 |
} |
| 457 | 783 |
let stopThreshold = resolvedStopThreshold( |
@@ -472,6 +798,9 @@ final class ChargeInsightsStore {
|
||
| 472 | 798 |
) else {
|
| 473 | 799 |
return |
| 474 | 800 |
} |
| 801 |
+ if let powerbankSource, let powerbankIDString = stringValue(powerbankSource, key: "id") {
|
|
| 802 |
+ session.setValue(powerbankIDString, forKey: "sourcePowerbankID") |
|
| 803 |
+ } |
|
| 475 | 804 |
|
| 476 | 805 |
if startsFromFlatBattery {
|
| 477 | 806 |
session.setValue(unresolvedFlatBatteryPercent, forKey: "startBatteryPercent") |
@@ -654,7 +983,9 @@ final class ChargeInsightsStore {
|
||
| 654 | 983 |
func addBatteryCheckpoint( |
| 655 | 984 |
percent: Double, |
| 656 | 985 |
for sessionID: UUID, |
| 657 |
- measuredEnergyWh: Double? = nil |
|
| 986 |
+ measuredEnergyWh: Double? = nil, |
|
| 987 |
+ subject: CheckpointSubject = .chargedDevice, |
|
| 988 |
+ barsValue: Int = 0 |
|
| 658 | 989 |
) -> Bool {
|
| 659 | 990 |
guard percent.isFinite, percent >= 0, percent <= 100 else {
|
| 660 | 991 |
return false |
@@ -670,6 +1001,8 @@ final class ChargeInsightsStore {
|
||
| 670 | 1001 |
percent: percent, |
| 671 | 1002 |
measuredEnergyWh: measuredEnergyWh, |
| 672 | 1003 |
flag: .intermediate, |
| 1004 |
+ subject: subject, |
|
| 1005 |
+ barsValue: barsValue, |
|
| 673 | 1006 |
to: session |
| 674 | 1007 |
) |
| 675 | 1008 |
} |
@@ -1243,6 +1576,8 @@ final class ChargeInsightsStore {
|
||
| 1243 | 1576 |
deviceClass: deviceClass, |
| 1244 | 1577 |
deviceTemplateID: stringValue(device, key: "deviceTemplateID"), |
| 1245 | 1578 |
templateDefinition: templateDefinition, |
| 1579 |
+ profileID: stringValue(device, key: "profileID"), |
|
| 1580 |
+ hasInternalSubject: (device.value(forKey: "hasInternalSubject") as? Bool) ?? false, |
|
| 1246 | 1581 |
supportsChargingWhileOff: chargingStateAvailability.supportsChargingWhileOff, |
| 1247 | 1582 |
chargingStateAvailability: chargingStateAvailability, |
| 1248 | 1583 |
supportsWiredCharging: supportsWiredCharging, |
@@ -1312,6 +1647,118 @@ final class ChargeInsightsStore {
|
||
| 1312 | 1647 |
return summary |
| 1313 | 1648 |
} |
| 1314 | 1649 |
|
| 1650 |
+ /// Materialize all Powerbank entities into Swift summaries, with sessions in which the |
|
| 1651 |
+ /// powerbank participates either as the charged subject (`chargedPowerbankID`) or as the |
|
| 1652 |
+ /// supplying source (`sourcePowerbankID`). Discharge curve aggregation across multiple |
|
| 1653 |
+ /// concurrent device-side sessions is derived view-side from `sessionsAsSource`. |
|
| 1654 |
+ func fetchPowerbankSummaries() -> [PowerbankSummary] {
|
|
| 1655 |
+ var summaries: [PowerbankSummary] = [] |
|
| 1656 |
+ |
|
| 1657 |
+ context.performAndWait {
|
|
| 1658 |
+ let powerbanks = fetchObjects(entityName: EntityName.powerbank) |
|
| 1659 |
+ guard !powerbanks.isEmpty else {
|
|
| 1660 |
+ return |
|
| 1661 |
+ } |
|
| 1662 |
+ |
|
| 1663 |
+ let sessions = fetchObjects(entityName: EntityName.chargeSession) |
|
| 1664 |
+ let checkpoints = fetchObjects(entityName: EntityName.chargeCheckpoint) |
|
| 1665 |
+ let checkpointsBySessionID = Dictionary(grouping: checkpoints) {
|
|
| 1666 |
+ stringValue($0, key: "sessionID") ?? "" |
|
| 1667 |
+ } |
|
| 1668 |
+ let sessionsAsSubject = Dictionary(grouping: sessions) {
|
|
| 1669 |
+ stringValue($0, key: "chargedPowerbankID") ?? "" |
|
| 1670 |
+ } |
|
| 1671 |
+ let sessionsAsSource = Dictionary(grouping: sessions) {
|
|
| 1672 |
+ stringValue($0, key: "sourcePowerbankID") ?? "" |
|
| 1673 |
+ } |
|
| 1674 |
+ |
|
| 1675 |
+ summaries = powerbanks.compactMap { powerbank in
|
|
| 1676 |
+ guard |
|
| 1677 |
+ let id = uuidValue(powerbank, key: "id"), |
|
| 1678 |
+ let name = stringValue(powerbank, key: "name"), |
|
| 1679 |
+ let qrIdentifier = stringValue(powerbank, key: "qrIdentifier") |
|
| 1680 |
+ else {
|
|
| 1681 |
+ return nil |
|
| 1682 |
+ } |
|
| 1683 |
+ |
|
| 1684 |
+ let templateID = stringValue(powerbank, key: "deviceTemplateID") |
|
| 1685 |
+ let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID) |
|
| 1686 |
+ |
|
| 1687 |
+ let reportingRaw = stringValue(powerbank, key: "batteryLevelReportingRawValue") |
|
| 1688 |
+ let reporting = reportingRaw.flatMap(BatteryLevelReporting.init(rawValue:)) ?? .percent |
|
| 1689 |
+ let barsCount = Int(optionalInt16Value(powerbank, key: "batteryBarsCount") ?? 0) |
|
| 1690 |
+ |
|
| 1691 |
+ let sessionsAsSubjectRaw = sessionsAsSubject[id.uuidString] ?? [] |
|
| 1692 |
+ let sessionsAsSourceRaw = sessionsAsSource[id.uuidString] ?? [] |
|
| 1693 |
+ |
|
| 1694 |
+ let subjectSessions = sessionsAsSubjectRaw |
|
| 1695 |
+ .compactMap { session -> ChargeSessionSummary? in
|
|
| 1696 |
+ let sessionID = stringValue(session, key: "id") ?? "" |
|
| 1697 |
+ return makeSessionSummary( |
|
| 1698 |
+ from: session, |
|
| 1699 |
+ checkpoints: checkpointsBySessionID[sessionID] ?? [], |
|
| 1700 |
+ samples: [] |
|
| 1701 |
+ ) |
|
| 1702 |
+ } |
|
| 1703 |
+ .sorted { $0.startedAt > $1.startedAt }
|
|
| 1704 |
+ |
|
| 1705 |
+ let sourceSessions = sessionsAsSourceRaw |
|
| 1706 |
+ .compactMap { session -> ChargeSessionSummary? in
|
|
| 1707 |
+ let sessionID = stringValue(session, key: "id") ?? "" |
|
| 1708 |
+ return makeSessionSummary( |
|
| 1709 |
+ from: session, |
|
| 1710 |
+ checkpoints: checkpointsBySessionID[sessionID] ?? [], |
|
| 1711 |
+ samples: [] |
|
| 1712 |
+ ) |
|
| 1713 |
+ } |
|
| 1714 |
+ .sorted { $0.startedAt > $1.startedAt }
|
|
| 1715 |
+ |
|
| 1716 |
+ let observedVoltages: [Double] = (stringValue(powerbank, key: "sourceObservedVoltageSelectionsRawValue") ?? "") |
|
| 1717 |
+ .split(separator: ",") |
|
| 1718 |
+ .compactMap { Double($0) }
|
|
| 1719 |
+ .sorted() |
|
| 1720 |
+ |
|
| 1721 |
+ let derived = derivedPowerbankMetrics( |
|
| 1722 |
+ sessionsAsSubject: subjectSessions, |
|
| 1723 |
+ sessionsAsSource: sourceSessions, |
|
| 1724 |
+ reporting: reporting |
|
| 1725 |
+ ) |
|
| 1726 |
+ |
|
| 1727 |
+ return PowerbankSummary( |
|
| 1728 |
+ id: id, |
|
| 1729 |
+ qrIdentifier: qrIdentifier, |
|
| 1730 |
+ name: name, |
|
| 1731 |
+ deviceTemplateID: templateID, |
|
| 1732 |
+ templateDefinition: templateDefinition, |
|
| 1733 |
+ batteryLevelReporting: reporting, |
|
| 1734 |
+ batteryBarsCount: barsCount, |
|
| 1735 |
+ estimatedBatteryCapacityWh: optionalDoubleValue(powerbank, key: "estimatedBatteryCapacityWh"), |
|
| 1736 |
+ apparentCapacityWh: derived.apparentCapacityWh |
|
| 1737 |
+ ?? optionalDoubleValue(powerbank, key: "apparentCapacityWh"), |
|
| 1738 |
+ configuredCompletionCurrentAmps: optionalDoubleValue(powerbank, key: "configuredCompletionCurrentAmps"), |
|
| 1739 |
+ learnedCompletionCurrentAmps: optionalDoubleValue(powerbank, key: "learnedCompletionCurrentAmps"), |
|
| 1740 |
+ minimumCurrentAmps: optionalDoubleValue(powerbank, key: "minimumCurrentAmps"), |
|
| 1741 |
+ sourceObservedVoltageSelections: derived.voltageMaxCurrents.keys.sorted().isEmpty |
|
| 1742 |
+ ? observedVoltages |
|
| 1743 |
+ : derived.voltageMaxCurrents.keys.sorted(), |
|
| 1744 |
+ sourceVoltageMaxCurrents: derived.voltageMaxCurrents, |
|
| 1745 |
+ sourceIdleCurrentAmps: optionalDoubleValue(powerbank, key: "sourceIdleCurrentAmps"), |
|
| 1746 |
+ sourceMaximumPowerWatts: derived.maxPowerWatts |
|
| 1747 |
+ ?? optionalDoubleValue(powerbank, key: "sourceMaximumPowerWatts"), |
|
| 1748 |
+ sourceEfficiencyFactor: derived.efficiencyFactor |
|
| 1749 |
+ ?? optionalDoubleValue(powerbank, key: "sourceEfficiencyFactor"), |
|
| 1750 |
+ notes: stringValue(powerbank, key: "notes"), |
|
| 1751 |
+ createdAt: dateValue(powerbank, key: "createdAt") ?? Date(), |
|
| 1752 |
+ updatedAt: dateValue(powerbank, key: "updatedAt") ?? Date(), |
|
| 1753 |
+ sessionsAsSubject: subjectSessions, |
|
| 1754 |
+ sessionsAsSource: sourceSessions |
|
| 1755 |
+ ) |
|
| 1756 |
+ } |
|
| 1757 |
+ } |
|
| 1758 |
+ |
|
| 1759 |
+ return summaries |
|
| 1760 |
+ } |
|
| 1761 |
+ |
|
| 1315 | 1762 |
private func createSessionObject( |
| 1316 | 1763 |
for chargedDevice: NSManagedObject, |
| 1317 | 1764 |
charger: NSManagedObject?, |
@@ -2180,6 +2627,8 @@ final class ChargeInsightsStore {
|
||
| 2180 | 2627 |
flag: ChargeCheckpointFlag, |
| 2181 | 2628 |
timestamp: Date = Date(), |
| 2182 | 2629 |
measuredEnergyWhOverride: Double? = nil, |
| 2630 |
+ subject: CheckpointSubject = .chargedDevice, |
|
| 2631 |
+ barsValue: Int = 0, |
|
| 2183 | 2632 |
to session: NSManagedObject |
| 2184 | 2633 |
) -> String? {
|
| 2185 | 2634 |
guard |
@@ -2196,7 +2645,18 @@ final class ChargeInsightsStore {
|
||
| 2196 | 2645 |
?? doubleValue(session, key: "measuredEnergyWh") |
| 2197 | 2646 |
checkpoint.setValue(UUID().uuidString, forKey: "id") |
| 2198 | 2647 |
checkpoint.setValue(sessionID, forKey: "sessionID") |
| 2199 |
- checkpoint.setValue(chargedDeviceID, forKey: "chargedDeviceID") |
|
| 2648 |
+ switch subject {
|
|
| 2649 |
+ case .chargedDevice: |
|
| 2650 |
+ checkpoint.setValue(chargedDeviceID, forKey: "chargedDeviceID") |
|
| 2651 |
+ checkpoint.setValue(nil, forKey: "powerbankID") |
|
| 2652 |
+ case .powerbank: |
|
| 2653 |
+ // Powerbank-side checkpoint: link to the powerbank source instead. ChargedDeviceID |
|
| 2654 |
+ // stays nil so device capacity learning ignores it; the session backref is via sessionID. |
|
| 2655 |
+ let powerbankID = stringValue(session, key: "sourcePowerbankID") |
|
| 2656 |
+ checkpoint.setValue(nil, forKey: "chargedDeviceID") |
|
| 2657 |
+ checkpoint.setValue(powerbankID, forKey: "powerbankID") |
|
| 2658 |
+ } |
|
| 2659 |
+ checkpoint.setValue(Int16(max(0, barsValue)), forKey: "batteryBarsValue") |
|
| 2200 | 2660 |
checkpoint.setValue(timestamp, forKey: "timestamp") |
| 2201 | 2661 |
checkpoint.setValue(percent, forKey: "batteryPercent") |
| 2202 | 2662 |
checkpoint.setValue(checkpointEnergyWh, forKey: "measuredEnergyWh") |
@@ -2208,12 +2668,15 @@ final class ChargeInsightsStore {
|
||
| 2208 | 2668 |
checkpoint.setValue(flag.rawValue, forKey: "label") |
| 2209 | 2669 |
checkpoint.setValue(timestamp, forKey: "createdAt") |
| 2210 | 2670 |
|
| 2211 |
- let existingStartBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent") |
|
| 2212 |
- if existingStartBatteryPercent == nil || ((existingStartBatteryPercent ?? 0) < 0 && percent > 0) {
|
|
| 2213 |
- session.setValue(percent, forKey: "startBatteryPercent") |
|
| 2214 |
- } |
|
| 2215 |
- if (existingStartBatteryPercent ?? 0) >= 0 || percent > 0 {
|
|
| 2216 |
- session.setValue(percent, forKey: "endBatteryPercent") |
|
| 2671 |
+ // Session start/end battery percent fields track the device subject only. |
|
| 2672 |
+ if subject == .chargedDevice {
|
|
| 2673 |
+ let existingStartBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent") |
|
| 2674 |
+ if existingStartBatteryPercent == nil || ((existingStartBatteryPercent ?? 0) < 0 && percent > 0) {
|
|
| 2675 |
+ session.setValue(percent, forKey: "startBatteryPercent") |
|
| 2676 |
+ } |
|
| 2677 |
+ if (existingStartBatteryPercent ?? 0) >= 0 || percent > 0 {
|
|
| 2678 |
+ session.setValue(percent, forKey: "endBatteryPercent") |
|
| 2679 |
+ } |
|
| 2217 | 2680 |
} |
| 2218 | 2681 |
session.setValue(timestamp, forKey: "updatedAt") |
| 2219 | 2682 |
updateCapacityEstimate(for: session) |
@@ -2247,6 +2710,8 @@ final class ChargeInsightsStore {
|
||
| 2247 | 2710 |
percent: Double, |
| 2248 | 2711 |
measuredEnergyWh: Double? = nil, |
| 2249 | 2712 |
flag: ChargeCheckpointFlag, |
| 2713 |
+ subject: CheckpointSubject = .chargedDevice, |
|
| 2714 |
+ barsValue: Int = 0, |
|
| 2250 | 2715 |
to session: NSManagedObject, |
| 2251 | 2716 |
timestamp: Date = Date() |
| 2252 | 2717 |
) -> Bool {
|
@@ -2259,6 +2724,8 @@ final class ChargeInsightsStore {
|
||
| 2259 | 2724 |
flag: flag, |
| 2260 | 2725 |
timestamp: timestamp, |
| 2261 | 2726 |
measuredEnergyWhOverride: measuredEnergyWh, |
| 2727 |
+ subject: subject, |
|
| 2728 |
+ barsValue: barsValue, |
|
| 2262 | 2729 |
to: session |
| 2263 | 2730 |
) else {
|
| 2264 | 2731 |
return false |
@@ -2268,7 +2735,12 @@ final class ChargeInsightsStore {
|
||
| 2268 | 2735 |
return false |
| 2269 | 2736 |
} |
| 2270 | 2737 |
|
| 2271 |
- refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID) |
|
| 2738 |
+ // Device-subject checkpoints feed device-side capacity learning. Powerbank-subject |
|
| 2739 |
+ // checkpoints feed powerbank-side derivation, which is computed at materialization time |
|
| 2740 |
+ // (see PowerbankSummary fetch path). |
|
| 2741 |
+ if subject == .chargedDevice {
|
|
| 2742 |
+ refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID) |
|
| 2743 |
+ } |
|
| 2272 | 2744 |
return saveContext() |
| 2273 | 2745 |
} |
| 2274 | 2746 |
|
@@ -2626,7 +3098,9 @@ final class ChargeInsightsStore {
|
||
| 2626 | 3098 |
return ChargeSessionSummary( |
| 2627 | 3099 |
id: id, |
| 2628 | 3100 |
chargedDeviceID: chargedDeviceID, |
| 3101 |
+ chargedPowerbankID: uuidValue(object, key: "chargedPowerbankID"), |
|
| 2629 | 3102 |
chargerID: uuidValue(object, key: "chargerID"), |
| 3103 |
+ sourcePowerbankID: uuidValue(object, key: "sourcePowerbankID"), |
|
| 2630 | 3104 |
meterMACAddress: stringValue(object, key: "meterMACAddress"), |
| 2631 | 3105 |
meterName: stringValue(object, key: "meterName"), |
| 2632 | 3106 |
meterModel: stringValue(object, key: "meterModel"), |
@@ -2690,6 +3164,8 @@ final class ChargeInsightsStore {
|
||
| 2690 | 3164 |
id: id, |
| 2691 | 3165 |
sessionID: sessionID, |
| 2692 | 3166 |
chargedDeviceID: chargedDeviceID, |
| 3167 |
+ powerbankID: uuidValue(object, key: "powerbankID"), |
|
| 3168 |
+ batteryBarsValue: Int(optionalInt16Value(object, key: "batteryBarsValue") ?? 0), |
|
| 2693 | 3169 |
timestamp: timestamp, |
| 2694 | 3170 |
batteryPercent: doubleValue(object, key: "batteryPercent"), |
| 2695 | 3171 |
measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"), |
@@ -2823,6 +3299,20 @@ final class ChargeInsightsStore {
|
||
| 2823 | 3299 |
return (try? context.fetch(request)) ?? [] |
| 2824 | 3300 |
} |
| 2825 | 3301 |
|
| 3302 |
+ private func fetchSessions(forPowerbankSubjectID powerbankID: String) -> [NSManagedObject] {
|
|
| 3303 |
+ let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession) |
|
| 3304 |
+ request.predicate = NSPredicate(format: "chargedPowerbankID == %@", powerbankID) |
|
| 3305 |
+ request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)] |
|
| 3306 |
+ return (try? context.fetch(request)) ?? [] |
|
| 3307 |
+ } |
|
| 3308 |
+ |
|
| 3309 |
+ private func fetchSessions(forPowerbankSourceID powerbankID: String) -> [NSManagedObject] {
|
|
| 3310 |
+ let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession) |
|
| 3311 |
+ request.predicate = NSPredicate(format: "sourcePowerbankID == %@", powerbankID) |
|
| 3312 |
+ request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)] |
|
| 3313 |
+ return (try? context.fetch(request)) ?? [] |
|
| 3314 |
+ } |
|
| 3315 |
+ |
|
| 2826 | 3316 |
private func sampleBackedSessionIDs( |
| 2827 | 3317 |
devices: [NSManagedObject], |
| 2828 | 3318 |
sessionsByDeviceID: [String: [NSManagedObject]], |
@@ -2920,6 +3410,13 @@ final class ChargeInsightsStore {
|
||
| 2920 | 3410 |
return (try? context.fetch(request))?.first |
| 2921 | 3411 |
} |
| 2922 | 3412 |
|
| 3413 |
+ private func fetchPowerbankObject(id: String) -> NSManagedObject? {
|
|
| 3414 |
+ let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.powerbank) |
|
| 3415 |
+ request.predicate = NSPredicate(format: "id == %@", id) |
|
| 3416 |
+ request.fetchLimit = 1 |
|
| 3417 |
+ return (try? context.fetch(request))?.first |
|
| 3418 |
+ } |
|
| 3419 |
+ |
|
| 2923 | 3420 |
private func fetchObjects(entityName: String) -> [NSManagedObject] {
|
| 2924 | 3421 |
let request = NSFetchRequest<NSManagedObject>(entityName: entityName) |
| 2925 | 3422 |
return (try? context.fetch(request)) ?? [] |
@@ -2996,19 +3493,123 @@ final class ChargeInsightsStore {
|
||
| 2996 | 3493 |
return templateDefinition.id |
| 2997 | 3494 |
} |
| 2998 | 3495 |
|
| 2999 |
- private func templateDefinition(for chargedDevice: NSManagedObject) -> ChargedDeviceTemplateDefinition? {
|
|
| 3000 |
- guard let templateID = stringValue(chargedDevice, key: "deviceTemplateID"), |
|
| 3001 |
- let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID), |
|
| 3002 |
- templateDefinition.kind == deviceClass(for: chargedDevice).kind else {
|
|
| 3496 |
+ /// Resolves the active DeviceProfile for a ChargedDevice — catalog first |
|
| 3497 |
+ /// (covers all built-in templates and chargers), then DB-backed custom profiles. |
|
| 3498 |
+ /// Returns nil only for devices that escaped migration (shouldn't happen post Phase 3). |
|
| 3499 |
+ private func resolvedProfileDefinition(for chargedDevice: NSManagedObject) -> DeviceProfileDefinition? {
|
|
| 3500 |
+ if let profileID = stringValue(chargedDevice, key: "profileID") {
|
|
| 3501 |
+ if let catalogProfile = DeviceProfileCatalog.shared.profile(id: profileID) {
|
|
| 3502 |
+ return catalogProfile |
|
| 3503 |
+ } |
|
| 3504 |
+ if let stored = fetchDeviceProfileObject(id: profileID), |
|
| 3505 |
+ let definition = makeProfileDefinition(from: stored) {
|
|
| 3506 |
+ return definition |
|
| 3507 |
+ } |
|
| 3508 |
+ } |
|
| 3509 |
+ // Pre-migration fallback: try the legacy template ID against the catalog. |
|
| 3510 |
+ if let templateID = stringValue(chargedDevice, key: "deviceTemplateID"), |
|
| 3511 |
+ let catalogProfile = DeviceProfileCatalog.shared.profile(id: templateID) {
|
|
| 3512 |
+ return catalogProfile |
|
| 3513 |
+ } |
|
| 3514 |
+ return nil |
|
| 3515 |
+ } |
|
| 3516 |
+ |
|
| 3517 |
+ private func fetchDeviceProfileObject(id: String) -> NSManagedObject? {
|
|
| 3518 |
+ let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.deviceProfile) |
|
| 3519 |
+ request.predicate = NSPredicate(format: "id == %@", id) |
|
| 3520 |
+ request.fetchLimit = 1 |
|
| 3521 |
+ return (try? context.fetch(request))?.first |
|
| 3522 |
+ } |
|
| 3523 |
+ |
|
| 3524 |
+ private func makeProfileDefinition(from object: NSManagedObject) -> DeviceProfileDefinition? {
|
|
| 3525 |
+ guard let id = stringValue(object, key: "id"), |
|
| 3526 |
+ let categoryRaw = stringValue(object, key: "categoryRawValue"), |
|
| 3527 |
+ let category = ProfileCategory(rawValue: categoryRaw) else {
|
|
| 3003 | 3528 |
return nil |
| 3004 | 3529 |
} |
| 3005 |
- return templateDefinition |
|
| 3530 |
+ let name = stringValue(object, key: "name") ?? id |
|
| 3531 |
+ let group = stringValue(object, key: "group") ?? "Custom" |
|
| 3532 |
+ let iconName = stringValue(object, key: "iconSymbolName") ?? category.symbolName |
|
| 3533 |
+ let iconFallback = stringValue(object, key: "iconFallbackSymbolName") |
|
| 3534 |
+ let icon = ChargedDeviceTemplateIcon(type: .systemSymbol, name: iconName, fallbackSystemName: iconFallback) |
|
| 3535 |
+ let stateRaw = stringValue(object, key: "capChargingStateAvailabilityRawValue") |
|
| 3536 |
+ ?? ChargingStateAvailability.onOrOff.rawValue |
|
| 3537 |
+ let stateAvailability = ChargingStateAvailability(rawValue: stateRaw) ?? .onOrOff |
|
| 3538 |
+ let allowedWirelessProfiles = DeviceProfileDefinition.decodeWirelessProfilesCSV( |
|
| 3539 |
+ stringValue(object, key: "capWirelessProfilesRawValue") |
|
| 3540 |
+ ) |
|
| 3541 |
+ let defaultWirelessProfileRaw = stringValue(object, key: "defaultWirelessChargingProfileRawValue") |
|
| 3542 |
+ let defaultWirelessProfile = defaultWirelessProfileRaw.flatMap(WirelessChargingProfile.init(rawValue:)) |
|
| 3543 |
+ |
|
| 3544 |
+ return DeviceProfileDefinition( |
|
| 3545 |
+ id: id, |
|
| 3546 |
+ name: name, |
|
| 3547 |
+ group: group, |
|
| 3548 |
+ category: category, |
|
| 3549 |
+ icon: icon, |
|
| 3550 |
+ sortOrder: Int((object.value(forKey: "sortOrder") as? Int32) ?? 1000), |
|
| 3551 |
+ capWiredCharging: (object.value(forKey: "capWiredCharging") as? Bool) ?? false, |
|
| 3552 |
+ capWirelessCharging: (object.value(forKey: "capWirelessCharging") as? Bool) ?? false, |
|
| 3553 |
+ capWirelessProfiles: allowedWirelessProfiles, |
|
| 3554 |
+ capChargingStateAvailability: stateAvailability, |
|
| 3555 |
+ capHasInternalSubject: (object.value(forKey: "capHasInternalSubject") as? Bool) ?? false, |
|
| 3556 |
+ defaultWirelessChargingProfile: defaultWirelessProfile, |
|
| 3557 |
+ defaultWiredMinimumCurrentAmps: optionalDoubleValue(object, key: "defaultWiredMinimumCurrentAmps"), |
|
| 3558 |
+ defaultWirelessMinimumCurrentAmps: optionalDoubleValue(object, key: "defaultWirelessMinimumCurrentAmps"), |
|
| 3559 |
+ defaultWiredEstimatedBatteryCapacityWh: optionalDoubleValue(object, key: "defaultWiredEstimatedBatteryCapacityWh"), |
|
| 3560 |
+ defaultWirelessEstimatedBatteryCapacityWh: optionalDoubleValue(object, key: "defaultWirelessEstimatedBatteryCapacityWh") |
|
| 3561 |
+ ) |
|
| 3562 |
+ } |
|
| 3563 |
+ |
|
| 3564 |
+ /// Synthesises a `ChargedDeviceTemplateDefinition` from the active profile so the |
|
| 3565 |
+ /// existing UI surfaces (icons, group titles, capability summaries) keep working |
|
| 3566 |
+ /// without forking. Catalog profiles return their canonical template; custom |
|
| 3567 |
+ /// profiles return a definition derived from the profile's persisted shape. |
|
| 3568 |
+ private func templateDefinition(for chargedDevice: NSManagedObject) -> ChargedDeviceTemplateDefinition? {
|
|
| 3569 |
+ guard let profile = resolvedProfileDefinition(for: chargedDevice) else { return nil }
|
|
| 3570 |
+ |
|
| 3571 |
+ let kind = profile.category.kind |
|
| 3572 |
+ // Pick a representative wireless profile for the legacy template shape. |
|
| 3573 |
+ let wirelessProfile = profile.defaultWirelessChargingProfile |
|
| 3574 |
+ ?? profile.capWirelessProfiles.first |
|
| 3575 |
+ ?? .genericQi |
|
| 3576 |
+ |
|
| 3577 |
+ return ChargedDeviceTemplateDefinition( |
|
| 3578 |
+ id: profile.id, |
|
| 3579 |
+ name: profile.name, |
|
| 3580 |
+ group: profile.group, |
|
| 3581 |
+ kind: kind, |
|
| 3582 |
+ deviceClass: legacyClass(for: profile.category), |
|
| 3583 |
+ icon: profile.icon, |
|
| 3584 |
+ chargingStateAvailability: profile.capChargingStateAvailability, |
|
| 3585 |
+ supportsWiredCharging: profile.capWiredCharging, |
|
| 3586 |
+ supportsWirelessCharging: profile.capWirelessCharging, |
|
| 3587 |
+ wirelessChargingProfile: wirelessProfile, |
|
| 3588 |
+ sortOrder: profile.sortOrder |
|
| 3589 |
+ ) |
|
| 3590 |
+ } |
|
| 3591 |
+ |
|
| 3592 |
+ private func legacyClass(for category: ProfileCategory) -> ChargedDeviceClass {
|
|
| 3593 |
+ switch category {
|
|
| 3594 |
+ case .phone: return .iphone |
|
| 3595 |
+ case .watch: return .watch |
|
| 3596 |
+ case .powerbank: return .powerbank |
|
| 3597 |
+ case .charger: return .charger |
|
| 3598 |
+ case .tablet, .laptop, .audioAccessory, .accessoryCase, .other: return .other |
|
| 3599 |
+ } |
|
| 3006 | 3600 |
} |
| 3007 | 3601 |
|
| 3008 | 3602 |
private func supportsWiredCharging(for chargedDevice: NSManagedObject) -> Bool {
|
| 3009 | 3603 |
let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil |
| 3010 | 3604 |
? true |
| 3011 | 3605 |
: boolValue(chargedDevice, key: "supportsWiredCharging") |
| 3606 |
+ |
|
| 3607 |
+ if let profile = resolvedProfileDefinition(for: chargedDevice) {
|
|
| 3608 |
+ // Profile capability is the upper bound; user opt-out preserved. |
|
| 3609 |
+ return persistedWiredCharging && profile.capWiredCharging |
|
| 3610 |
+ } |
|
| 3611 |
+ |
|
| 3612 |
+ // Pre-migration fallback: legacy class enforcement. |
|
| 3012 | 3613 |
let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil |
| 3013 | 3614 |
? false |
| 3014 | 3615 |
: boolValue(chargedDevice, key: "supportsWirelessCharging") |
@@ -3019,12 +3620,17 @@ final class ChargeInsightsStore {
|
||
| 3019 | 3620 |
} |
| 3020 | 3621 |
|
| 3021 | 3622 |
private func supportsWirelessCharging(for chargedDevice: NSManagedObject) -> Bool {
|
| 3022 |
- let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil |
|
| 3023 |
- ? true |
|
| 3024 |
- : boolValue(chargedDevice, key: "supportsWiredCharging") |
|
| 3025 | 3623 |
let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil |
| 3026 | 3624 |
? false |
| 3027 | 3625 |
: boolValue(chargedDevice, key: "supportsWirelessCharging") |
| 3626 |
+ |
|
| 3627 |
+ if let profile = resolvedProfileDefinition(for: chargedDevice) {
|
|
| 3628 |
+ return persistedWirelessCharging && profile.capWirelessCharging |
|
| 3629 |
+ } |
|
| 3630 |
+ |
|
| 3631 |
+ let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil |
|
| 3632 |
+ ? true |
|
| 3633 |
+ : boolValue(chargedDevice, key: "supportsWiredCharging") |
|
| 3028 | 3634 |
return deviceClass(for: chargedDevice).normalizedChargingSupport( |
| 3029 | 3635 |
supportsWiredCharging: persistedWiredCharging, |
| 3030 | 3636 |
supportsWirelessCharging: persistedWirelessCharging |
@@ -3032,6 +3638,10 @@ final class ChargeInsightsStore {
|
||
| 3032 | 3638 |
} |
| 3033 | 3639 |
|
| 3034 | 3640 |
private func chargingStateAvailability(for chargedDevice: NSManagedObject) -> ChargingStateAvailability {
|
| 3641 |
+ if let profile = resolvedProfileDefinition(for: chargedDevice) {
|
|
| 3642 |
+ return profile.capChargingStateAvailability |
|
| 3643 |
+ } |
|
| 3644 |
+ |
|
| 3035 | 3645 |
let persistedAvailability = stringValue(chargedDevice, key: "chargingStateAvailabilityRawValue") |
| 3036 | 3646 |
.flatMap(ChargingStateAvailability.init(rawValue:)) |
| 3037 | 3647 |
?? ChargingStateAvailability.fallback( |
@@ -3101,11 +3711,21 @@ final class ChargeInsightsStore {
|
||
| 3101 | 3711 |
if let type = chargerType(for: chargedDevice) {
|
| 3102 | 3712 |
return type.wirelessChargingProfile |
| 3103 | 3713 |
} |
| 3104 |
- guard let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"), |
|
| 3105 |
- let profile = WirelessChargingProfile(rawValue: rawValue) else {
|
|
| 3106 |
- return .genericQi |
|
| 3714 |
+ let persisted = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue") |
|
| 3715 |
+ .flatMap(WirelessChargingProfile.init(rawValue:)) |
|
| 3716 |
+ |
|
| 3717 |
+ if let profile = resolvedProfileDefinition(for: chargedDevice), |
|
| 3718 |
+ !profile.capWirelessProfiles.isEmpty {
|
|
| 3719 |
+ // Persisted wins iff still allowed by capabilities; else fall back to profile default. |
|
| 3720 |
+ if let persisted, profile.capWirelessProfiles.contains(persisted) {
|
|
| 3721 |
+ return persisted |
|
| 3722 |
+ } |
|
| 3723 |
+ return profile.defaultWirelessChargingProfile |
|
| 3724 |
+ ?? profile.capWirelessProfiles.first |
|
| 3725 |
+ ?? .genericQi |
|
| 3107 | 3726 |
} |
| 3108 |
- return profile |
|
| 3727 |
+ |
|
| 3728 |
+ return persisted ?? .genericQi |
|
| 3109 | 3729 |
} |
| 3110 | 3730 |
|
| 3111 | 3731 |
private func resolvedPreferredChargingTransportMode( |
@@ -3409,6 +4029,101 @@ final class ChargeInsightsStore {
|
||
| 3409 | 4029 |
.max() |
| 3410 | 4030 |
} |
| 3411 | 4031 |
|
| 4032 |
+ /// View-time derivation of powerbank metrics from materialized session summaries. |
|
| 4033 |
+ /// Mirrors the charger derivation pattern but works on `ChargeSessionSummary` (already |
|
| 4034 |
+ /// projected) so we don't need to re-fetch NSManagedObjects. |
|
| 4035 |
+ /// - voltage profile: groups source-side sessions by selected voltage palier (rounded |
|
| 4036 |
+ /// to 0.5V) and tracks max current observed at each palier. |
|
| 4037 |
+ /// - max power: max across source-side sessions' `maximumObservedPowerWatts`. |
|
| 4038 |
+ /// - efficiency: ratio of total Wh delivered (as source) vs total Wh received (as subject). |
|
| 4039 |
+ /// Computed only when both sides have non-trivial energy logged. |
|
| 4040 |
+ /// - apparent capacity: sum of source-side delivered Wh between the most recent two |
|
| 4041 |
+ /// powerbank-side checkpoints with sufficient battery percent delta. Best-effort. |
|
| 4042 |
+ private func derivedPowerbankMetrics( |
|
| 4043 |
+ sessionsAsSubject: [ChargeSessionSummary], |
|
| 4044 |
+ sessionsAsSource: [ChargeSessionSummary], |
|
| 4045 |
+ reporting: BatteryLevelReporting |
|
| 4046 |
+ ) -> ( |
|
| 4047 |
+ voltageMaxCurrents: [Double: Double], |
|
| 4048 |
+ maxPowerWatts: Double?, |
|
| 4049 |
+ efficiencyFactor: Double?, |
|
| 4050 |
+ apparentCapacityWh: Double? |
|
| 4051 |
+ ) {
|
|
| 4052 |
+ var voltageMaxCurrents: [Double: Double] = [:] |
|
| 4053 |
+ var maxPower: Double? = nil |
|
| 4054 |
+ |
|
| 4055 |
+ for session in sessionsAsSource {
|
|
| 4056 |
+ if let voltage = session.selectedSourceVoltageVolts, voltage > 0 {
|
|
| 4057 |
+ let palier = (voltage * 2).rounded() / 2 // 0.5V buckets |
|
| 4058 |
+ if let maxCurrent = session.maximumObservedCurrentAmps, maxCurrent > 0 {
|
|
| 4059 |
+ let prev = voltageMaxCurrents[palier] ?? 0 |
|
| 4060 |
+ voltageMaxCurrents[palier] = max(prev, maxCurrent) |
|
| 4061 |
+ } |
|
| 4062 |
+ } |
|
| 4063 |
+ if let power = session.maximumObservedPowerWatts, power > 0 {
|
|
| 4064 |
+ maxPower = max(maxPower ?? 0, power) |
|
| 4065 |
+ } |
|
| 4066 |
+ } |
|
| 4067 |
+ |
|
| 4068 |
+ let totalDelivered = sessionsAsSource.reduce(0.0) { $0 + $1.measuredEnergyWh }
|
|
| 4069 |
+ let totalReceived = sessionsAsSubject.reduce(0.0) { $0 + $1.measuredEnergyWh }
|
|
| 4070 |
+ let efficiency: Double? = (totalDelivered > 0.5 && totalReceived > 0.5) |
|
| 4071 |
+ ? totalDelivered / totalReceived |
|
| 4072 |
+ : nil |
|
| 4073 |
+ |
|
| 4074 |
+ // Apparent capacity heuristics depend on what reporting the powerbank supports: |
|
| 4075 |
+ // |
|
| 4076 |
+ // - `.fullOnly`: the only honest signal is the "full" anchor. We look for two |
|
| 4077 |
+ // consecutive full markers (one before a discharge cycle, one after the next |
|
| 4078 |
+ // recharge) and use the source-side energy delivered between them. Single-LED |
|
| 4079 |
+ // powerbanks naturally produce two 100% datapoints separated by usage. |
|
| 4080 |
+ // - `.percent` / `.bars`: pair the most recent powerbank-side checkpoints with a |
|
| 4081 |
+ // meaningful battery delta (≥ 30%) and sum source-side energy in that window. |
|
| 4082 |
+ // - `.none`: no powerbank checkpoints exist, so apparent capacity stays nil here |
|
| 4083 |
+ // and would have to be inferred differently (currently not attempted). |
|
| 4084 |
+ var apparentCapacity: Double? = nil |
|
| 4085 |
+ |
|
| 4086 |
+ let powerbankCheckpoints = (sessionsAsSource + sessionsAsSubject) |
|
| 4087 |
+ .flatMap { $0.checkpoints.filter { $0.subject == .powerbank } }
|
|
| 4088 |
+ .sorted { $0.timestamp < $1.timestamp }
|
|
| 4089 |
+ |
|
| 4090 |
+ switch reporting {
|
|
| 4091 |
+ case .fullOnly: |
|
| 4092 |
+ let fullMarkers = powerbankCheckpoints.filter { $0.batteryPercent >= 99 }
|
|
| 4093 |
+ if let lastFull = fullMarkers.last, |
|
| 4094 |
+ let prevFull = fullMarkers.dropLast().last {
|
|
| 4095 |
+ let lower = prevFull.timestamp |
|
| 4096 |
+ let upper = lastFull.timestamp |
|
| 4097 |
+ let energyDelivered = sessionsAsSource |
|
| 4098 |
+ .filter { $0.startedAt <= upper && ($0.endedAt ?? $0.lastObservedAt) >= lower }
|
|
| 4099 |
+ .reduce(0.0) { $0 + $1.measuredEnergyWh }
|
|
| 4100 |
+ if energyDelivered > 0.1 {
|
|
| 4101 |
+ apparentCapacity = energyDelivered |
|
| 4102 |
+ } |
|
| 4103 |
+ } |
|
| 4104 |
+ case .percent, .bars: |
|
| 4105 |
+ if powerbankCheckpoints.count >= 2 {
|
|
| 4106 |
+ let last = powerbankCheckpoints.last! |
|
| 4107 |
+ if let earlier = powerbankCheckpoints.first(where: {
|
|
| 4108 |
+ abs(last.batteryPercent - $0.batteryPercent) >= 30 |
|
| 4109 |
+ }) {
|
|
| 4110 |
+ let lower = min(earlier.timestamp, last.timestamp) |
|
| 4111 |
+ let upper = max(earlier.timestamp, last.timestamp) |
|
| 4112 |
+ let energyDelivered = sessionsAsSource |
|
| 4113 |
+ .filter { $0.startedAt <= upper && ($0.endedAt ?? $0.lastObservedAt) >= lower }
|
|
| 4114 |
+ .reduce(0.0) { $0 + $1.measuredEnergyWh }
|
|
| 4115 |
+ if energyDelivered > 0.1 {
|
|
| 4116 |
+ apparentCapacity = energyDelivered |
|
| 4117 |
+ } |
|
| 4118 |
+ } |
|
| 4119 |
+ } |
|
| 4120 |
+ case .none: |
|
| 4121 |
+ break |
|
| 4122 |
+ } |
|
| 4123 |
+ |
|
| 4124 |
+ return (voltageMaxCurrents, maxPower, efficiency, apparentCapacity) |
|
| 4125 |
+ } |
|
| 4126 |
+ |
|
| 3412 | 4127 |
private func chargingTransportMode(for session: NSManagedObject) -> ChargingTransportMode {
|
| 3413 | 4128 |
if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"), |
| 3414 | 4129 |
let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
|
@@ -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 |
+} |
|
@@ -355,6 +355,13 @@ struct ChargedDeviceSettingsView: View {
|
||
| 355 | 355 |
MeterInfoRowView(label: "Template", value: chargedDevice.identityTitle) |
| 356 | 356 |
MeterInfoRowView(label: "QR ID", value: chargedDevice.qrIdentifier) |
| 357 | 357 |
|
| 358 |
+ if chargedDevice.supportsInternalSubject {
|
|
| 359 |
+ MeterInfoRowView( |
|
| 360 |
+ label: "Subject", |
|
| 361 |
+ value: chargedDevice.hasInternalSubject ? "Inside" : "Empty" |
|
| 362 |
+ ) |
|
| 363 |
+ } |
|
| 364 |
+ |
|
| 358 | 365 |
MeterInfoRowView(label: "Created", value: chargedDevice.createdAt.format()) |
| 359 | 366 |
MeterInfoRowView(label: "Updated", value: chargedDevice.updatedAt.format()) |
| 360 | 367 |
|
@@ -18,6 +18,8 @@ struct BatteryCheckpointEditorContentView: View {
|
||
| 18 | 18 |
let showsHeader: Bool |
| 19 | 19 |
|
| 20 | 20 |
@State private var batteryPercent = "" |
| 21 |
+ @State private var barsValue: Int = 0 |
|
| 22 |
+ @State private var subject: CheckpointSubject = .chargedDevice |
|
| 21 | 23 |
@State private var showsWarningPopover = false |
| 22 | 24 |
|
| 23 | 25 |
init( |
@@ -36,6 +38,29 @@ struct BatteryCheckpointEditorContentView: View {
|
||
| 36 | 38 |
self.showsHeader = showsHeader |
| 37 | 39 |
} |
| 38 | 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 allowsSubjectToggle: Bool {
|
|
| 50 |
+ sourcePowerbank?.batteryLevelReporting.allowsCheckpoints == true |
|
| 51 |
+ } |
|
| 52 |
+ |
|
| 53 |
+ private var activeReporting: BatteryLevelReporting {
|
|
| 54 |
+ if subject == .powerbank, let sourcePowerbank {
|
|
| 55 |
+ return sourcePowerbank.batteryLevelReporting |
|
| 56 |
+ } |
|
| 57 |
+ return .percent |
|
| 58 |
+ } |
|
| 59 |
+ |
|
| 60 |
+ private var activeBarsCount: Int {
|
|
| 61 |
+ max(1, sourcePowerbank?.batteryBarsCount ?? 1) |
|
| 62 |
+ } |
|
| 63 |
+ |
|
| 39 | 64 |
private var plausibilityWarning: BatteryCheckpointPlausibilityWarning? {
|
| 40 | 65 |
guard let percent = normalizedBatteryPercent else {
|
| 41 | 66 |
return nil |
@@ -55,10 +80,18 @@ struct BatteryCheckpointEditorContentView: View {
|
||
| 55 | 80 |
} |
| 56 | 81 |
|
| 57 | 82 |
private var canSave: Bool {
|
| 58 |
- guard let percent = normalizedBatteryPercent else {
|
|
| 83 |
+ switch activeReporting {
|
|
| 84 |
+ case .percent: |
|
| 85 |
+ guard let percent = normalizedBatteryPercent else { return false }
|
|
| 86 |
+ return percent >= 0 && percent <= 100 |
|
| 87 |
+ case .bars: |
|
| 88 |
+ return barsValue >= 0 && barsValue <= activeBarsCount |
|
| 89 |
+ case .fullOnly: |
|
| 90 |
+ // Always savable — the only emitted value is the 100% anchor. |
|
| 91 |
+ return true |
|
| 92 |
+ case .none: |
|
| 59 | 93 |
return false |
| 60 | 94 |
} |
| 61 |
- return percent >= 0 && percent <= 100 |
|
| 62 | 95 |
} |
| 63 | 96 |
|
| 64 | 97 |
var body: some View {
|
@@ -74,17 +107,53 @@ struct BatteryCheckpointEditorContentView: View {
|
||
| 74 | 107 |
} |
| 75 | 108 |
} |
| 76 | 109 |
|
| 110 |
+ if allowsSubjectToggle {
|
|
| 111 |
+ Picker("Subject", selection: $subject) {
|
|
| 112 |
+ Text("Device").tag(CheckpointSubject.chargedDevice)
|
|
| 113 |
+ Text("Powerbank").tag(CheckpointSubject.powerbank)
|
|
| 114 |
+ } |
|
| 115 |
+ .pickerStyle(.segmented) |
|
| 116 |
+ } |
|
| 117 |
+ |
|
| 77 | 118 |
compactEditorRow |
| 78 | 119 |
} |
| 79 | 120 |
} |
| 80 | 121 |
|
| 81 |
- private var compactEditorRow: some View {
|
|
| 82 |
- HStack(spacing: 8) {
|
|
| 122 |
+ @ViewBuilder |
|
| 123 |
+ private var subjectInput: some View {
|
|
| 124 |
+ switch activeReporting {
|
|
| 125 |
+ case .percent: |
|
| 83 | 126 |
TextField("Battery %", text: $batteryPercent)
|
| 84 | 127 |
.keyboardType(.decimalPad) |
| 85 | 128 |
.textFieldStyle(.roundedBorder) |
| 86 | 129 |
.frame(width: 104) |
| 87 | 130 |
.onSubmit(saveCheckpoint) |
| 131 |
+ case .bars: |
|
| 132 |
+ HStack(spacing: 6) {
|
|
| 133 |
+ Stepper(value: $barsValue, in: 0...activeBarsCount) {
|
|
| 134 |
+ Text("\(barsValue) / \(activeBarsCount)")
|
|
| 135 |
+ .font(.subheadline) |
|
| 136 |
+ } |
|
| 137 |
+ .frame(width: 160) |
|
| 138 |
+ } |
|
| 139 |
+ case .fullOnly: |
|
| 140 |
+ // Single-LED powerbanks only signal completion. The only meaningful checkpoint |
|
| 141 |
+ // is "full" — anything else would be a guess. Tapping the action saves at 100%. |
|
| 142 |
+ Label("Full LED is on", systemImage: "lightbulb.fill")
|
|
| 143 |
+ .font(.caption) |
|
| 144 |
+ .foregroundColor(.secondary) |
|
| 145 |
+ .frame(width: 220, alignment: .leading) |
|
| 146 |
+ case .none: |
|
| 147 |
+ Text("Battery level reporting disabled")
|
|
| 148 |
+ .font(.caption) |
|
| 149 |
+ .foregroundColor(.secondary) |
|
| 150 |
+ .frame(width: 220, alignment: .leading) |
|
| 151 |
+ } |
|
| 152 |
+ } |
|
| 153 |
+ |
|
| 154 |
+ private var compactEditorRow: some View {
|
|
| 155 |
+ HStack(spacing: 8) {
|
|
| 156 |
+ subjectInput |
|
| 88 | 157 |
|
| 89 | 158 |
if let plausibilityWarning {
|
| 90 | 159 |
Button {
|
@@ -157,14 +226,32 @@ struct BatteryCheckpointEditorContentView: View {
|
||
| 157 | 226 |
} |
| 158 | 227 |
|
| 159 | 228 |
private func saveCheckpoint() {
|
| 160 |
- guard let percent = normalizedBatteryPercent else {
|
|
| 229 |
+ let resolvedPercent: Double |
|
| 230 |
+ let resolvedBars: Int |
|
| 231 |
+ switch activeReporting {
|
|
| 232 |
+ case .percent: |
|
| 233 |
+ guard let percent = normalizedBatteryPercent else { return }
|
|
| 234 |
+ resolvedPercent = percent |
|
| 235 |
+ resolvedBars = 0 |
|
| 236 |
+ case .bars: |
|
| 237 |
+ resolvedBars = barsValue |
|
| 238 |
+ resolvedPercent = activeBarsCount > 0 |
|
| 239 |
+ ? min(100, max(0, Double(barsValue) / Double(activeBarsCount) * 100)) |
|
| 240 |
+ : 0 |
|
| 241 |
+ case .fullOnly: |
|
| 242 |
+ // Single-LED powerbanks: the only meaningful anchor is "full". |
|
| 243 |
+ resolvedPercent = 100 |
|
| 244 |
+ resolvedBars = 0 |
|
| 245 |
+ case .none: |
|
| 161 | 246 |
return |
| 162 | 247 |
} |
| 163 | 248 |
|
| 164 | 249 |
if appData.addBatteryCheckpoint( |
| 165 |
- percent: percent, |
|
| 250 |
+ percent: resolvedPercent, |
|
| 166 | 251 |
for: sessionID, |
| 167 |
- measuredEnergyWh: effectiveEnergyWhOverride |
|
| 252 |
+ measuredEnergyWh: effectiveEnergyWhOverride, |
|
| 253 |
+ subject: subject, |
|
| 254 |
+ barsValue: resolvedBars |
|
| 168 | 255 |
) {
|
| 169 | 256 |
onSaved?() |
| 170 | 257 |
} |
@@ -16,12 +16,13 @@ struct ChargedDeviceEditorSheetView: View {
|
||
| 16 | 16 |
@State private var name: String |
| 17 | 17 |
@State private var notes: String |
| 18 | 18 |
@State private var deviceClass: ChargedDeviceClass |
| 19 |
- @State private var selectedTemplateID: String? |
|
| 20 |
- @State private var lastAppliedTemplateID: String? |
|
| 19 |
+ @State private var selectedProfileID: String? |
|
| 20 |
+ @State private var lastAppliedProfileID: String? |
|
| 21 | 21 |
@State private var chargingStateAvailability: ChargingStateAvailability |
| 22 | 22 |
@State private var supportsWiredCharging: Bool |
| 23 | 23 |
@State private var supportsWirelessCharging: Bool |
| 24 | 24 |
@State private var wirelessChargingProfile: WirelessChargingProfile |
| 25 |
+ @State private var hasInternalSubject: Bool |
|
| 25 | 26 |
@State private var completionCurrentTexts: [ChargeSessionKind: String] |
| 26 | 27 |
|
| 27 | 28 |
let standalone: Bool |
@@ -37,22 +38,60 @@ struct ChargedDeviceEditorSheetView: View {
|
||
| 37 | 38 |
_notes = State(initialValue: chargedDevice?.notes ?? "") |
| 38 | 39 |
|
| 39 | 40 |
let initialDeviceClass = chargedDevice?.deviceClass ?? .iphone |
| 40 |
- let initialChargingStateAvailability = initialDeviceClass.normalizedChargingStateAvailability( |
|
| 41 |
- chargedDevice?.chargingStateAvailability ?? initialDeviceClass.defaultChargingStateAvailability |
|
| 42 |
- ) |
|
| 43 |
- let defaultChargingSupport = initialDeviceClass.defaultChargingSupport |
|
| 44 |
- let initialChargingSupport = initialDeviceClass.normalizedChargingSupport( |
|
| 45 |
- supportsWiredCharging: chargedDevice?.supportsWiredCharging ?? defaultChargingSupport.wired, |
|
| 46 |
- supportsWirelessCharging: chargedDevice?.supportsWirelessCharging ?? defaultChargingSupport.wireless |
|
| 47 |
- ) |
|
| 48 |
- 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 |
+ |
|
| 49 | 87 |
_deviceClass = State(initialValue: initialDeviceClass) |
| 50 |
- _selectedTemplateID = State(initialValue: initialTemplateID) |
|
| 51 |
- _lastAppliedTemplateID = State(initialValue: initialTemplateID) |
|
| 88 |
+ _selectedProfileID = State(initialValue: initialProfileID) |
|
| 89 |
+ _lastAppliedProfileID = State(initialValue: initialProfileID) |
|
| 52 | 90 |
_chargingStateAvailability = State(initialValue: initialChargingStateAvailability) |
| 53 |
- _supportsWiredCharging = State(initialValue: initialChargingSupport.wired) |
|
| 54 |
- _supportsWirelessCharging = State(initialValue: initialChargingSupport.wireless) |
|
| 55 |
- _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) |
|
| 56 | 95 |
_completionCurrentTexts = State(initialValue: Self.makeCompletionCurrentTexts(for: chargedDevice)) |
| 57 | 96 |
} |
| 58 | 97 |
|
@@ -65,38 +104,41 @@ struct ChargedDeviceEditorSheetView: View {
|
||
| 65 | 104 |
save: save |
| 66 | 105 |
) {
|
| 67 | 106 |
identitySection |
| 68 |
- templateSection |
|
| 107 |
+ profileSection |
|
| 108 |
+ if selectedProfile == nil {
|
|
| 109 |
+ customClassSection |
|
| 110 |
+ } |
|
| 69 | 111 |
deviceChargeBehaviourSection |
| 70 | 112 |
deviceChargingSupportSection |
| 113 |
+ if let profile = selectedProfile, profile.capHasInternalSubject {
|
|
| 114 |
+ internalSubjectSection(for: profile) |
|
| 115 |
+ } |
|
| 71 | 116 |
deviceCompletionSection |
| 72 | 117 |
notesSection |
| 73 | 118 |
} |
| 74 | 119 |
.onChange(of: deviceClass) { newValue in
|
| 75 |
- applyDeviceClassRules(for: newValue) |
|
| 120 |
+ applyDeviceClassRulesIfCustom(for: newValue) |
|
| 76 | 121 |
} |
| 77 |
- .onChange(of: selectedTemplateID) { newValue in
|
|
| 78 |
- applyTemplateSelection( |
|
| 79 |
- previousTemplateID: lastAppliedTemplateID, |
|
| 80 |
- newTemplateID: newValue |
|
| 122 |
+ .onChange(of: selectedProfileID) { newValue in
|
|
| 123 |
+ applyProfileSelection( |
|
| 124 |
+ previousProfileID: lastAppliedProfileID, |
|
| 125 |
+ newProfileID: newValue |
|
| 81 | 126 |
) |
| 82 |
- lastAppliedTemplateID = newValue |
|
| 127 |
+ lastAppliedProfileID = newValue |
|
| 83 | 128 |
} |
| 84 | 129 |
.onAppear {
|
| 85 |
- applyDeviceClassRules(for: deviceClass) |
|
| 130 |
+ if selectedProfile == nil {
|
|
| 131 |
+ applyDeviceClassRulesIfCustom(for: deviceClass) |
|
| 132 |
+ } |
|
| 86 | 133 |
} |
| 87 | 134 |
} |
| 88 | 135 |
|
| 136 |
+ // MARK: - Sections |
|
| 137 |
+ |
|
| 89 | 138 |
private var identitySection: some View {
|
| 90 | 139 |
Section(header: Text("Identity")) {
|
| 91 | 140 |
TextField("Name", text: $name)
|
| 92 | 141 |
|
| 93 |
- Picker("Class", selection: $deviceClass) {
|
|
| 94 |
- ForEach(ChargedDeviceClass.deviceCases) { deviceClass in
|
|
| 95 |
- Label(deviceClass.title, systemImage: deviceClass.symbolName) |
|
| 96 |
- .tag(deviceClass) |
|
| 97 |
- } |
|
| 98 |
- } |
|
| 99 |
- |
|
| 100 | 142 |
if let chargedDevice {
|
| 101 | 143 |
Text(chargedDevice.qrIdentifier) |
| 102 | 144 |
.font(.caption.monospaced()) |
@@ -106,45 +148,55 @@ struct ChargedDeviceEditorSheetView: View {
|
||
| 106 | 148 |
} |
| 107 | 149 |
} |
| 108 | 150 |
|
| 109 |
- private var templateSection: some View {
|
|
| 151 |
+ private var profileSection: some View {
|
|
| 110 | 152 |
Section( |
| 111 | 153 |
header: ContextInfoHeader( |
| 112 |
- title: "Template", |
|
| 113 |
- 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." |
|
| 114 | 156 |
) |
| 115 | 157 |
) {
|
| 116 |
- Picker("Template", selection: $selectedTemplateID) {
|
|
| 117 |
- Text("Custom")
|
|
| 118 |
- .tag(String?.none) |
|
| 158 |
+ Picker("Profile", selection: $selectedProfileID) {
|
|
| 159 |
+ Text("Custom").tag(String?.none)
|
|
| 119 | 160 |
|
| 120 |
- ForEach(groupedTemplates, id: \.group) { group in
|
|
| 161 |
+ ForEach(groupedProfiles, id: \.group) { group in
|
|
| 121 | 162 |
Section(group.group) {
|
| 122 |
- ForEach(group.templates) { template in
|
|
| 123 |
- Text(template.name) |
|
| 124 |
- .tag(template.id as String?) |
|
| 163 |
+ ForEach(group.profiles) { profile in
|
|
| 164 |
+ Text(profile.name).tag(profile.id as String?) |
|
| 125 | 165 |
} |
| 126 | 166 |
} |
| 127 | 167 |
} |
| 128 | 168 |
} |
| 129 | 169 |
|
| 130 |
- if let selectedTemplate {
|
|
| 131 |
- ChargedDeviceTemplateLabelView( |
|
| 132 |
- template: selectedTemplate, |
|
| 133 |
- iconPointSize: 18 |
|
| 134 |
- ) |
|
| 135 |
- .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)) |
|
| 136 | 176 |
|
| 137 |
- Text(selectedTemplate.capabilitySummary) |
|
| 138 |
- .font(.caption) |
|
| 139 |
- .foregroundColor(.secondary) |
|
| 177 |
+ Text(profile.capabilitySummary) |
|
| 178 |
+ .font(.caption) |
|
| 179 |
+ .foregroundColor(.secondary) |
|
| 180 |
+ } |
|
| 140 | 181 |
} else {
|
| 141 |
- 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.")
|
|
| 142 | 183 |
.font(.caption) |
| 143 | 184 |
.foregroundColor(.secondary) |
| 144 | 185 |
} |
| 145 | 186 |
} |
| 146 | 187 |
} |
| 147 | 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 |
+ |
|
| 148 | 200 |
private var deviceChargeBehaviourSection: some View {
|
| 149 | 201 |
Section( |
| 150 | 202 |
header: ContextInfoHeader( |
@@ -152,7 +204,23 @@ struct ChargedDeviceEditorSheetView: View {
|
||
| 152 | 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." |
| 153 | 205 |
) |
| 154 | 206 |
) {
|
| 155 |
- 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 {
|
|
| 156 | 224 |
VStack(alignment: .leading, spacing: 6) {
|
| 157 | 225 |
Label(enforcedChargingStateAvailability.title, systemImage: "lock.fill") |
| 158 | 226 |
.font(.subheadline.weight(.semibold)) |
@@ -163,8 +231,7 @@ struct ChargedDeviceEditorSheetView: View {
|
||
| 163 | 231 |
} else {
|
| 164 | 232 |
Picker("Session Modes", selection: $chargingStateAvailability) {
|
| 165 | 233 |
ForEach(ChargingStateAvailability.allCases) { availability in
|
| 166 |
- Text(availability.title) |
|
| 167 |
- .tag(availability) |
|
| 234 |
+ Text(availability.title).tag(availability) |
|
| 168 | 235 |
} |
| 169 | 236 |
} |
| 170 | 237 |
} |
@@ -178,7 +245,9 @@ struct ChargedDeviceEditorSheetView: View {
|
||
| 178 | 245 |
message: "Enable the charging methods this device actually supports. Wireless devices can also keep a dedicated profile so learning stays separate." |
| 179 | 246 |
) |
| 180 | 247 |
) {
|
| 181 |
- if let enforcedChargingSupport = deviceClass.enforcedChargingSupport {
|
|
| 248 |
+ if let profile = selectedProfile {
|
|
| 249 |
+ profileChargingSupportRows(for: profile) |
|
| 250 |
+ } else if let enforcedChargingSupport = deviceClass.enforcedChargingSupport {
|
|
| 182 | 251 |
VStack(alignment: .leading, spacing: 6) {
|
| 183 | 252 |
Label( |
| 184 | 253 |
Self.chargingSupportDescription( |
@@ -200,12 +269,10 @@ struct ChargedDeviceEditorSheetView: View {
|
||
| 200 | 269 |
|
| 201 | 270 |
if showsWirelessProfilePicker {
|
| 202 | 271 |
Picker("Wireless profile", selection: $wirelessChargingProfile) {
|
| 203 |
- ForEach(WirelessChargingProfile.allCases) { profile in
|
|
| 204 |
- Text(profile.title) |
|
| 205 |
- .tag(profile) |
|
| 272 |
+ ForEach(availableWirelessProfiles) { profile in
|
|
| 273 |
+ Text(profile.title).tag(profile) |
|
| 206 | 274 |
} |
| 207 | 275 |
} |
| 208 |
- |
|
| 209 | 276 |
} |
| 210 | 277 |
|
| 211 | 278 |
if supportedChargingModes.isEmpty {
|
@@ -216,6 +283,43 @@ struct ChargedDeviceEditorSheetView: View {
|
||
| 216 | 283 |
} |
| 217 | 284 |
} |
| 218 | 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 |
+ |
|
| 219 | 323 |
private var deviceCompletionSection: some View {
|
| 220 | 324 |
Section( |
| 221 | 325 |
header: ContextInfoHeader( |
@@ -248,6 +352,8 @@ struct ChargedDeviceEditorSheetView: View {
|
||
| 248 | 352 |
} |
| 249 | 353 |
} |
| 250 | 354 |
|
| 355 |
+ // MARK: - Computed |
|
| 356 |
+ |
|
| 251 | 357 |
private var editorTitle: String {
|
| 252 | 358 |
chargedDevice == nil ? "New Device" : "Edit Device" |
| 253 | 359 |
} |
@@ -262,52 +368,52 @@ struct ChargedDeviceEditorSheetView: View {
|
||
| 262 | 368 |
&& !hasInvalidCompletionCurrentEntry |
| 263 | 369 |
} |
| 264 | 370 |
|
| 265 |
- private var availableTemplates: [ChargedDeviceTemplateDefinition] {
|
|
| 266 |
- ChargedDeviceTemplateCatalog.shared.templates(for: .device) |
|
| 371 |
+ private var availableProfiles: [DeviceProfileDefinition] {
|
|
| 372 |
+ DeviceProfileCatalog.shared.profiles.filter { $0.category.kind == .device }
|
|
| 267 | 373 |
} |
| 268 | 374 |
|
| 269 |
- private var groupedTemplates: [(group: String, templates: [ChargedDeviceTemplateDefinition])] {
|
|
| 270 |
- Dictionary(grouping: availableTemplates, by: \.group) |
|
| 375 |
+ private var groupedProfiles: [(group: String, profiles: [DeviceProfileDefinition])] {
|
|
| 376 |
+ Dictionary(grouping: availableProfiles, by: \.group) |
|
| 271 | 377 |
.keys |
| 272 | 378 |
.sorted() |
| 273 | 379 |
.map { group in
|
| 274 |
- ( |
|
| 275 |
- group: group, |
|
| 276 |
- templates: availableTemplates.filter { $0.group == group }
|
|
| 277 |
- ) |
|
| 380 |
+ (group: group, profiles: availableProfiles.filter { $0.group == group })
|
|
| 278 | 381 |
} |
| 279 | 382 |
} |
| 280 | 383 |
|
| 281 |
- private var selectedTemplate: ChargedDeviceTemplateDefinition? {
|
|
| 282 |
- ChargedDeviceTemplateCatalog.shared.template(id: selectedTemplateID) |
|
| 384 |
+ private var selectedProfile: DeviceProfileDefinition? {
|
|
| 385 |
+ DeviceProfileCatalog.shared.profile(id: selectedProfileID) |
|
| 283 | 386 |
} |
| 284 | 387 |
|
| 285 | 388 |
private var supportedChargingModes: [ChargingTransportMode] {
|
| 286 | 389 |
var modes: [ChargingTransportMode] = [] |
| 287 |
- if supportsWiredCharging {
|
|
| 288 |
- modes.append(.wired) |
|
| 289 |
- } |
|
| 290 |
- if supportsWirelessCharging {
|
|
| 291 |
- modes.append(.wireless) |
|
| 292 |
- } |
|
| 390 |
+ if supportsWiredCharging { modes.append(.wired) }
|
|
| 391 |
+ if supportsWirelessCharging { modes.append(.wireless) }
|
|
| 293 | 392 |
return modes |
| 294 | 393 |
} |
| 295 | 394 |
|
| 296 | 395 |
private var applicableSessionKinds: [ChargeSessionKind] {
|
| 297 |
- supportedChargingModes.flatMap { chargingTransportMode in
|
|
| 298 |
- chargingStateAvailability.supportedModes.map { chargingStateMode in
|
|
| 299 |
- ChargeSessionKind( |
|
| 300 |
- chargingTransportMode: chargingTransportMode, |
|
| 301 |
- chargingStateMode: chargingStateMode |
|
| 302 |
- ) |
|
| 396 |
+ supportedChargingModes.flatMap { transportMode in
|
|
| 397 |
+ chargingStateAvailability.supportedModes.map { stateMode in
|
|
| 398 |
+ ChargeSessionKind(chargingTransportMode: transportMode, chargingStateMode: stateMode) |
|
| 303 | 399 |
} |
| 304 | 400 |
} |
| 305 | 401 |
} |
| 306 | 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 |
+ |
|
| 307 | 410 |
private var showsWirelessProfilePicker: Bool {
|
| 308 |
- supportsWirelessCharging |
|
| 309 |
- && deviceClass != .watch |
|
| 310 |
- && 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 |
|
| 311 | 417 |
} |
| 312 | 418 |
|
| 313 | 419 |
private var parsedCompletionCurrents: [ChargeSessionKind: Double] {
|
@@ -334,16 +440,32 @@ struct ChargedDeviceEditorSheetView: View {
|
||
| 334 | 440 |
) |
| 335 | 441 |
} |
| 336 | 442 |
|
| 443 |
+ // MARK: - Save & application |
|
| 444 |
+ |
|
| 337 | 445 |
private func save() {
|
| 338 | 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 |
+ |
|
| 339 | 459 |
let didSave: Bool |
| 340 | 460 |
|
| 341 | 461 |
if let chargedDevice {
|
| 342 | 462 |
didSave = appData.updateDevice( |
| 343 | 463 |
id: chargedDevice.id, |
| 344 | 464 |
name: name, |
| 345 |
- deviceClass: deviceClass, |
|
| 346 |
- templateID: selectedTemplateID, |
|
| 465 |
+ deviceClass: resolvedDeviceClass, |
|
| 466 |
+ templateID: resolvedTemplateID, |
|
| 467 |
+ profileID: selectedProfileID, |
|
| 468 |
+ hasInternalSubject: hasInternalSubject, |
|
| 347 | 469 |
chargingStateAvailability: chargingStateAvailability, |
| 348 | 470 |
supportsWiredCharging: supportsWiredCharging, |
| 349 | 471 |
supportsWirelessCharging: supportsWirelessCharging, |
@@ -354,8 +476,10 @@ struct ChargedDeviceEditorSheetView: View {
|
||
| 354 | 476 |
} else {
|
| 355 | 477 |
didSave = appData.createDevice( |
| 356 | 478 |
name: name, |
| 357 |
- deviceClass: deviceClass, |
|
| 358 |
- templateID: selectedTemplateID, |
|
| 479 |
+ deviceClass: resolvedDeviceClass, |
|
| 480 |
+ templateID: resolvedTemplateID, |
|
| 481 |
+ profileID: selectedProfileID, |
|
| 482 |
+ hasInternalSubject: hasInternalSubject, |
|
| 359 | 483 |
chargingStateAvailability: chargingStateAvailability, |
| 360 | 484 |
supportsWiredCharging: supportsWiredCharging, |
| 361 | 485 |
supportsWirelessCharging: supportsWirelessCharging, |
@@ -370,48 +494,39 @@ struct ChargedDeviceEditorSheetView: View {
|
||
| 370 | 494 |
} |
| 371 | 495 |
} |
| 372 | 496 |
|
| 373 |
- private func applyTemplateSelection( |
|
| 374 |
- previousTemplateID: String?, |
|
| 375 |
- newTemplateID: String? |
|
| 497 |
+ private func applyProfileSelection( |
|
| 498 |
+ previousProfileID: String?, |
|
| 499 |
+ newProfileID: String? |
|
| 376 | 500 |
) {
|
| 377 |
- 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) |
|
| 378 | 510 |
return |
| 379 | 511 |
} |
| 380 | 512 |
|
| 381 |
- let previousTemplate = ChargedDeviceTemplateCatalog.shared.template(id: previousTemplateID) |
|
| 382 |
- let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) |
|
| 383 |
- if trimmedName.isEmpty || trimmedName == previousTemplate?.name {
|
|
| 384 |
- name = newTemplate.name |
|
| 513 |
+ if trimmedName.isEmpty || trimmedName == previousProfile?.name {
|
|
| 514 |
+ name = newProfile.name |
|
| 385 | 515 |
} |
| 386 | 516 |
|
| 387 |
- deviceClass = newTemplate.deviceClass |
|
| 388 |
- chargingStateAvailability = newTemplate.deviceClass.normalizedChargingStateAvailability( |
|
| 389 |
- newTemplate.chargingStateAvailability |
|
| 390 |
- ) |
|
| 391 |
- |
|
| 392 |
- let normalizedChargingSupport = newTemplate.deviceClass.normalizedChargingSupport( |
|
| 393 |
- supportsWiredCharging: newTemplate.supportsWiredCharging, |
|
| 394 |
- supportsWirelessCharging: newTemplate.supportsWirelessCharging |
|
| 395 |
- ) |
|
| 396 |
- supportsWiredCharging = normalizedChargingSupport.wired |
|
| 397 |
- supportsWirelessCharging = normalizedChargingSupport.wireless |
|
| 398 |
- 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) |
|
| 399 | 526 |
} |
| 400 | 527 |
|
| 401 |
- private func applyDeviceClassRules(for deviceClass: ChargedDeviceClass) {
|
|
| 402 |
- if let selectedTemplate {
|
|
| 403 |
- chargingStateAvailability = deviceClass.normalizedChargingStateAvailability( |
|
| 404 |
- selectedTemplate.chargingStateAvailability |
|
| 405 |
- ) |
|
| 406 |
- let normalizedChargingSupport = deviceClass.normalizedChargingSupport( |
|
| 407 |
- supportsWiredCharging: selectedTemplate.supportsWiredCharging, |
|
| 408 |
- supportsWirelessCharging: selectedTemplate.supportsWirelessCharging |
|
| 409 |
- ) |
|
| 410 |
- supportsWiredCharging = normalizedChargingSupport.wired |
|
| 411 |
- supportsWirelessCharging = normalizedChargingSupport.wireless |
|
| 412 |
- wirelessChargingProfile = selectedTemplate.wirelessChargingProfile |
|
| 413 |
- return |
|
| 414 |
- } |
|
| 528 |
+ private func applyDeviceClassRulesIfCustom(for deviceClass: ChargedDeviceClass) {
|
|
| 529 |
+ guard selectedProfile == nil else { return }
|
|
| 415 | 530 |
|
| 416 | 531 |
if let enforcedChargingStateAvailability = deviceClass.enforcedChargingStateAvailability {
|
| 417 | 532 |
chargingStateAvailability = enforcedChargingStateAvailability |
@@ -429,16 +544,23 @@ struct ChargedDeviceEditorSheetView: View {
|
||
| 429 | 544 |
} |
| 430 | 545 |
} |
| 431 | 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 |
+ |
|
| 432 | 558 |
private func parsedOptionalCurrent(_ text: String) -> Double? {
|
| 433 | 559 |
let normalized = text |
| 434 | 560 |
.trimmingCharacters(in: .whitespacesAndNewlines) |
| 435 | 561 |
.replacingOccurrences(of: ",", with: ".") |
| 436 |
- guard !normalized.isEmpty else {
|
|
| 437 |
- return nil |
|
| 438 |
- } |
|
| 439 |
- guard let value = Double(normalized), value > 0 else {
|
|
| 440 |
- return nil |
|
| 441 |
- } |
|
| 562 |
+ guard !normalized.isEmpty else { return nil }
|
|
| 563 |
+ guard let value = Double(normalized), value > 0 else { return nil }
|
|
| 442 | 564 |
return value |
| 443 | 565 |
} |
| 444 | 566 |
|
@@ -458,11 +580,23 @@ struct ChargedDeviceEditorSheetView: View {
|
||
| 458 | 580 |
} |
| 459 | 581 |
} |
| 460 | 582 |
|
| 461 |
- private static func makeCompletionCurrentTexts(for chargedDevice: ChargedDeviceSummary?) -> [ChargeSessionKind: String] {
|
|
| 462 |
- guard let chargedDevice else {
|
|
| 463 |
- 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 |
|
| 464 | 590 |
} |
| 591 |
+ if let templateID = chargedDevice.deviceTemplateID, |
|
| 592 |
+ DeviceProfileCatalog.shared.profile(id: templateID) != nil {
|
|
| 593 |
+ return templateID |
|
| 594 |
+ } |
|
| 595 |
+ return nil |
|
| 596 |
+ } |
|
| 465 | 597 |
|
| 598 |
+ private static func makeCompletionCurrentTexts(for chargedDevice: ChargedDeviceSummary?) -> [ChargeSessionKind: String] {
|
|
| 599 |
+ guard let chargedDevice else { return [:] }
|
|
| 466 | 600 |
return ChargeSessionKind.allCases.reduce(into: [ChargeSessionKind: String]()) { result, sessionKind in
|
| 467 | 601 |
result[sessionKind] = optionalCurrentText( |
| 468 | 602 |
chargedDevice.configuredCompletionCurrentAmps(for: sessionKind) |
@@ -471,9 +605,7 @@ struct ChargedDeviceEditorSheetView: View {
|
||
| 471 | 605 |
} |
| 472 | 606 |
|
| 473 | 607 |
private static func optionalCurrentText(_ value: Double?) -> String {
|
| 474 |
- guard let value else {
|
|
| 475 |
- return "" |
|
| 476 |
- } |
|
| 608 |
+ guard let value else { return "" }
|
|
| 477 | 609 |
return value.format(decimalDigits: 2) |
| 478 | 610 |
} |
| 479 | 611 |
|
@@ -482,15 +614,10 @@ struct ChargedDeviceEditorSheetView: View {
|
||
| 482 | 614 |
supportsWirelessCharging: Bool |
| 483 | 615 |
) -> String {
|
| 484 | 616 |
switch (supportsWiredCharging, supportsWirelessCharging) {
|
| 485 |
- case (true, true): |
|
| 486 |
- return "Supports wired and wireless charging" |
|
| 487 |
- case (true, false): |
|
| 488 |
- return "Supports wired charging only" |
|
| 489 |
- case (false, true): |
|
| 490 |
- return "Supports wireless charging only" |
|
| 491 |
- case (false, false): |
|
| 492 |
- 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" |
|
| 493 | 621 |
} |
| 494 | 622 |
} |
| 495 |
- |
|
| 496 | 623 |
} |
@@ -82,6 +82,7 @@ struct MeterChargeRecordContentView: View {
|
||
| 82 | 82 |
@State private var activeMode: ActiveMode = .chargeSession |
| 83 | 83 |
@State private var draftChargedDeviceID: UUID? |
| 84 | 84 |
@State private var draftChargerID: UUID? |
| 85 |
+ @State private var draftSourcePowerbankID: UUID? |
|
| 85 | 86 |
|
| 86 | 87 |
var body: some View {
|
| 87 | 88 |
Group {
|
@@ -177,6 +178,54 @@ struct MeterChargeRecordContentView: View {
|
||
| 177 | 178 |
appData.chargerSummaries |
| 178 | 179 |
} |
| 179 | 180 |
|
| 181 |
+ private var availablePowerbanks: [PowerbankSummary] {
|
|
| 182 |
+ appData.powerbankSummaries |
|
| 183 |
+ } |
|
| 184 |
+ |
|
| 185 |
+ private var selectedSourcePowerbank: PowerbankSummary? {
|
|
| 186 |
+ if let openChargeSession, |
|
| 187 |
+ let powerbankID = openChargeSession.sourcePowerbankID {
|
|
| 188 |
+ return availablePowerbanks.first { $0.id == powerbankID }
|
|
| 189 |
+ } |
|
| 190 |
+ guard let draftSourcePowerbankID else { return nil }
|
|
| 191 |
+ return availablePowerbanks.first { $0.id == draftSourcePowerbankID }
|
|
| 192 |
+ } |
|
| 193 |
+ |
|
| 194 |
+ /// Unified source selection encoding — packed into a String tag because SwiftUI Picker |
|
| 195 |
+ /// works best with hashable primitives. `none`, `charger:UUID`, or `powerbank:UUID`. |
|
| 196 |
+ private var selectedSourceTag: Binding<String> {
|
|
| 197 |
+ Binding( |
|
| 198 |
+ get: {
|
|
| 199 |
+ if let openChargeSession {
|
|
| 200 |
+ if let chargerID = openChargeSession.chargerID { return "charger:\(chargerID.uuidString)" }
|
|
| 201 |
+ if let powerbankID = openChargeSession.sourcePowerbankID { return "powerbank:\(powerbankID.uuidString)" }
|
|
| 202 |
+ return "none" |
|
| 203 |
+ } |
|
| 204 |
+ if let draftChargerID { return "charger:\(draftChargerID.uuidString)" }
|
|
| 205 |
+ if let draftSourcePowerbankID { return "powerbank:\(draftSourcePowerbankID.uuidString)" }
|
|
| 206 |
+ return "none" |
|
| 207 |
+ }, |
|
| 208 |
+ set: { newValue in
|
|
| 209 |
+ if newValue == "none" {
|
|
| 210 |
+ draftChargerID = nil |
|
| 211 |
+ draftSourcePowerbankID = nil |
|
| 212 |
+ } else if newValue.hasPrefix("charger:"),
|
|
| 213 |
+ let uuid = UUID(uuidString: String(newValue.dropFirst("charger:".count))) {
|
|
| 214 |
+ draftChargerID = uuid |
|
| 215 |
+ draftSourcePowerbankID = nil |
|
| 216 |
+ } else if newValue.hasPrefix("powerbank:"),
|
|
| 217 |
+ let uuid = UUID(uuidString: String(newValue.dropFirst("powerbank:".count))) {
|
|
| 218 |
+ draftChargerID = nil |
|
| 219 |
+ draftSourcePowerbankID = uuid |
|
| 220 |
+ } |
|
| 221 |
+ } |
|
| 222 |
+ ) |
|
| 223 |
+ } |
|
| 224 |
+ |
|
| 225 |
+ private var hasAnySource: Bool {
|
|
| 226 |
+ availableChargers.isEmpty == false || availablePowerbanks.isEmpty == false |
|
| 227 |
+ } |
|
| 228 |
+ |
|
| 180 | 229 |
private var selectedChargerID: Binding<UUID?> {
|
| 181 | 230 |
Binding( |
| 182 | 231 |
get: { openChargeSession?.chargerID ?? draftChargerID },
|
@@ -300,6 +349,30 @@ struct MeterChargeRecordContentView: View {
|
||
| 300 | 349 |
return transportMode == .wireless |
| 301 | 350 |
} |
| 302 | 351 |
|
| 352 |
+ /// Source section is visible whenever a transport is picked. For wired sessions only |
|
| 353 |
+ /// powerbanks are listed (chargers don't apply); for wireless both chargers and powerbanks |
|
| 354 |
+ /// can be picked. |
|
| 355 |
+ private var showsSourceSection: Bool {
|
|
| 356 |
+ guard selectedDraftTransportMode != nil || selectedChargedDevice != nil else { return false }
|
|
| 357 |
+ if showsWirelessChargerSection {
|
|
| 358 |
+ return hasAnySource |
|
| 359 |
+ } |
|
| 360 |
+ return availablePowerbanks.isEmpty == false |
|
| 361 |
+ } |
|
| 362 |
+ |
|
| 363 |
+ private var sourceSectionListsChargers: Bool {
|
|
| 364 |
+ showsWirelessChargerSection |
|
| 365 |
+ } |
|
| 366 |
+ |
|
| 367 |
+ private var sourcePromptText: String {
|
|
| 368 |
+ if showsWirelessChargerSection {
|
|
| 369 |
+ return availableChargers.isEmpty && availablePowerbanks.isEmpty |
|
| 370 |
+ ? "No source available" |
|
| 371 |
+ : "Choose source" |
|
| 372 |
+ } |
|
| 373 |
+ return availablePowerbanks.isEmpty ? "No powerbank available" : "Choose powerbank (optional)" |
|
| 374 |
+ } |
|
| 375 |
+ |
|
| 303 | 376 |
// MARK: - Status Header |
| 304 | 377 |
|
| 305 | 378 |
private var statusHeader: some View {
|
@@ -384,23 +457,31 @@ struct MeterChargeRecordContentView: View {
|
||
| 384 | 457 |
} |
| 385 | 458 |
} |
| 386 | 459 |
|
| 387 |
- // Wireless charger — appears immediately after Type when wireless is selected |
|
| 388 |
- if showsWirelessChargerSection {
|
|
| 460 |
+ // Source — charger (when wireless) and/or powerbank. None is always allowed. |
|
| 461 |
+ if showsSourceSection {
|
|
| 389 | 462 |
Divider().padding(.leading, 46) |
| 390 | 463 |
.transition(.opacity) |
| 391 | 464 |
setupRow(icon: "antenna.radiowaves.left.and.right", iconColor: .teal) {
|
| 392 |
- Picker(selection: selectedChargerID) {
|
|
| 393 |
- Text("Choose charger").tag(UUID?.none)
|
|
| 394 |
- ForEach(availableChargers) { charger in
|
|
| 395 |
- Text(charger.name).tag(Optional(charger.id)) |
|
| 465 |
+ Picker(selection: selectedSourceTag) {
|
|
| 466 |
+ Text("None").tag("none")
|
|
| 467 |
+ if sourceSectionListsChargers {
|
|
| 468 |
+ ForEach(availableChargers) { charger in
|
|
| 469 |
+ Text("Charger · \(charger.name)").tag("charger:\(charger.id.uuidString)")
|
|
| 470 |
+ } |
|
| 471 |
+ } |
|
| 472 |
+ ForEach(availablePowerbanks) { powerbank in
|
|
| 473 |
+ Text("Powerbank · \(powerbank.name)").tag("powerbank:\(powerbank.id.uuidString)")
|
|
| 396 | 474 |
} |
| 397 | 475 |
} label: {
|
| 398 | 476 |
HStack(spacing: 8) {
|
| 399 | 477 |
if let charger = selectedCharger {
|
| 400 | 478 |
ChargedDeviceIdentityLabelView(chargedDevice: charger, iconPointSize: 15) |
| 401 | 479 |
.font(.subheadline.weight(.semibold)) |
| 480 |
+ } else if let powerbank = selectedSourcePowerbank {
|
|
| 481 |
+ Label(powerbank.name, systemImage: powerbank.identitySymbolName) |
|
| 482 |
+ .font(.subheadline.weight(.semibold)) |
|
| 402 | 483 |
} else {
|
| 403 |
- Text(availableChargers.isEmpty ? "No chargers available" : "Choose charger") |
|
| 484 |
+ Text(sourcePromptText) |
|
| 404 | 485 |
.foregroundColor(.secondary) |
| 405 | 486 |
.font(.subheadline) |
| 406 | 487 |
} |
@@ -411,7 +492,6 @@ struct MeterChargeRecordContentView: View {
|
||
| 411 | 492 |
} |
| 412 | 493 |
} |
| 413 | 494 |
.pickerStyle(.menu) |
| 414 |
- .disabled(availableChargers.isEmpty) |
|
| 415 | 495 |
} |
| 416 | 496 |
.transition(.asymmetric( |
| 417 | 497 |
insertion: .move(edge: .top).combined(with: .opacity), |
@@ -658,10 +738,12 @@ struct MeterChargeRecordContentView: View {
|
||
| 658 | 738 |
} |
| 659 | 739 |
|
| 660 | 740 |
let chargerID = chargingTransportMode == .wireless ? selectedCharger?.id : nil |
| 741 |
+ let powerbankSourceID = selectedSourcePowerbank?.id |
|
| 661 | 742 |
let didStart = appData.startChargeSession( |
| 662 | 743 |
for: usbMeter, |
| 663 | 744 |
chargedDeviceID: selectedChargedDevice.id, |
| 664 | 745 |
chargerID: chargerID, |
| 746 |
+ sourcePowerbankID: powerbankSourceID, |
|
| 665 | 747 |
chargingTransportMode: chargingTransportMode, |
| 666 | 748 |
chargingStateMode: chargingStateMode, |
| 667 | 749 |
autoStopEnabled: false, |
@@ -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 |
+} |
|
@@ -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() |
@@ -66,6 +70,9 @@ struct SidebarView: View {
|
||
| 66 | 70 |
case .charger: |
| 67 | 71 |
ChargerEditorSheetView() |
| 68 | 72 |
.environmentObject(appData) |
| 73 |
+ case .powerbank: |
|
| 74 |
+ PowerbankEditorSheetView() |
|
| 75 |
+ .environmentObject(appData) |
|
| 69 | 76 |
} |
| 70 | 77 |
} |
| 71 | 78 |
} |
@@ -117,6 +124,20 @@ struct SidebarView: View {
|
||
| 117 | 124 |
}, |
| 118 | 125 |
onAdd: { creationSheet = .charger }
|
| 119 | 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 |
+ ) |
|
| 120 | 141 |
} |
| 121 | 142 |
} |
| 122 | 143 |
|