Showing 19 changed files with 2893 additions and 184 deletions
+85 -0
Documentation/Powerbank Category.md
@@ -0,0 +1,85 @@
1
+# Powerbank Category
2
+
3
+## Definition
4
+
5
+A **powerbank** is a device that has a battery *and* delivers power to other devices. It is conceptually both a charged device (when it is being charged) and a charger (when it is supplying another device's session). For that reason, in this app, a powerbank is a **first-class entity** — separate from `ChargedDevice` and from chargers (which are `ChargedDevice` rows with `deviceClass = .charger`).
6
+
7
+A powerbank can sit on **either side of the meter**:
8
+
9
+- **Subject side (input)** — the powerbank is being charged. The session looks like any device-charging session, except its subject is the powerbank.
10
+- **Source side (output)** — the powerbank is supplying energy to a device that is being charged. The session's subject is that device; the powerbank fills the source slot (the same slot a charger fills for wireless sessions).
11
+
12
+A powerbank can also be both at once if there are two meters in the line (pass-through case): one session on the input meter with the powerbank as subject, one session on the output meter with the powerbank as source. They are independent records linked only by their shared powerbank reference.
13
+
14
+## Battery level reporting
15
+
16
+Powerbanks report their battery in heterogeneous ways:
17
+
18
+- **`.percent`** — 0–100% (treated like any device).
19
+- **`.bars`** — discrete steps, e.g. 4 of 4. The `batteryBarsCount` attribute drives the resolution. Stored canonically as percent (`100 / barsCount * barIndex`); the bars value is also stored on the checkpoint for fidelity.
20
+- **`.fullOnly`** — a single LED that lights only when charging completes. The only honest checkpoint is the 100% anchor; the editor renders a "Full LED is on" affordance and stores `batteryPercent = 100`. Capacity learning treats two consecutive full markers as the bounds of a discharge-then-recharge cycle and uses the source-side energy between them as apparent capacity.
21
+- **`.none`** — no battery level visible. Powerbank-side checkpoints are disabled; capacity learning relies only on full-cycle session energy.
22
+
23
+The reason for keeping `.fullOnly` distinct from `.bars` with `count = 1` is data honesty: a 1-bar gauge would invite "0/1 = not full" entries as if they were datapoints, when in reality "not full" is uninformative (the battery could be anywhere from 0% to 99%). The `.fullOnly` editor only emits the precise signal and skips the meaningless one.
24
+
25
+This is configured in `PowerbankEditorSheetView`. The checkpoint editor (`BatteryCheckpointEditorContentView`) adapts: text input for percent, stepper for bars, disabled state with a hint for none.
26
+
27
+## Sessions and the source slot
28
+
29
+A `ChargeSession` carries:
30
+
31
+- one **subject**: either `chargedDeviceID` (existing) or `chargedPowerbankID` (new, when the powerbank itself is being charged)
32
+- at most one **source**: `chargerID` (existing) or `sourcePowerbankID` (new). May be empty.
33
+
34
+The subject and source columns are mutually exclusive within their pair (i.e. a session cannot have both a `chargedDeviceID` and a `chargedPowerbankID`; same for chargers vs powerbanks on the source side). The store enforces this at session-creation time.
35
+
36
+The session-start UI (`MeterChargeRecordTabView`) presents a **unified Source picker**:
37
+
38
+- For wireless transport: shows None + chargers + powerbanks.
39
+- For wired transport: shows None + powerbanks (chargers don't apply to wired).
40
+- Source is optional; "None" is always valid.
41
+
42
+## Multiple devices, one powerbank
43
+
44
+A powerbank can charge several devices simultaneously, each on its own meter. Each meter has its own `ChargeSession` referencing the same `sourcePowerbankID`. The powerbank's view aggregates across them at view time (no cached aggregate curve in storage). Per-session data stays per-session — this is the **curve duplication** rule: each device's curve lives independently, and the powerbank's visualization sums concurrent curves on the fly.
45
+
46
+The "one open session per meter" healing invariant ([Charge Session Integrity and Conflict Healing.md](Charge%20Session%20Integrity%20and%20Conflict%20Healing.md)) is unchanged: the source-side reference does not affect grouping. Two devices on two meters with the same powerbank source = two independent open sessions, no conflict.
47
+
48
+## Checkpoints with two subjects
49
+
50
+`ChargeCheckpoint` was extended with:
51
+
52
+- `powerbankID: String?` — populated when the checkpoint reflects the powerbank's battery state. Mutually exclusive with `chargedDeviceID`.
53
+- `batteryBarsValue: Int16` — the as-reported bars value (0 when not in bars mode).
54
+
55
+Powerbank-side checkpoints **do not** update the session's `startBatteryPercent` / `endBatteryPercent` fields — those track the device subject only. They also don't trigger device-side capacity learning. They are read at view time when computing powerbank-derived metrics.
56
+
57
+The subject toggle appears in the inline checkpoint editor only when the active session's source is a powerbank with `.percent` or `.bars` reporting.
58
+
59
+## Derived metrics
60
+
61
+Computed view-side at every powerbank summary materialization in `ChargeInsightsStore.fetchPowerbankSummaries()`:
62
+
63
+- **Voltage profile (`sourceVoltageMaxCurrents`)** — bucket source-side sessions by `selectedSourceVoltageVolts` rounded to 0.5V. Per bucket, track the maximum `maximumObservedCurrentAmps`. Surfaces what voltage steps the powerbank actually delivers and at what currents.
64
+- **`sourceMaximumPowerWatts`** — max of `maximumObservedPowerWatts` across all source-side sessions.
65
+- **`sourceEfficiencyFactor`** — `Σ Wh delivered (as source) / Σ Wh received (as subject)`. Computed only when both totals exceed 0.5 Wh.
66
+- **`apparentCapacityWh`** — best-effort: pick the most recent pair of powerbank-side checkpoints with ≥ 30 percent delta and sum the source-side energy across overlapping sessions in that window.
67
+
68
+Persistent fields with the same names exist on the `Powerbank` entity for future write-back; the materialization currently prefers the derived value and falls back to the persisted value when derivation isn't possible.
69
+
70
+## Migration
71
+
72
+Schema version: **USB_Meter 19** (additive — new entity + new optional attributes only; lightweight migration).
73
+
74
+Legacy `ChargedDevice` rows with `deviceClass = "powerbank"` are not yet migrated automatically. They continue to render as ordinary charged devices for now. A one-time migration that promotes them to the new `Powerbank` entity is planned but deferred until there are real legacy rows in CloudKit to test against — the current shape works correctly for clean installs and for upgraders who haven't yet created any class-`.powerbank` `ChargedDevice` rows.
75
+
76
+## Files
77
+
78
+- Schema: `USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 19.xcdatamodel/`
79
+- Model layer: `USB Meter/Model/ChargeInsightsModel.swift` (`PowerbankSummary`, `BatteryLevelReporting`, `CheckpointSubject`, `ChargeSessionSource`)
80
+- Store: `USB Meter/Model/ChargeInsightsStore.swift` (`createPowerbank`, `updatePowerbank`, `deletePowerbank`, `fetchPowerbankSummaries`, `derivedPowerbankMetrics`)
81
+- AppData: `USB Meter/Model/AppData.swift` (`powerbankSummaries`, CRUD wrappers, extended `startChargeSession`/`addBatteryCheckpoint`)
82
+- Views/Powerbanks/: `PowerbankEditorSheetView.swift`, `PowerbankDetailView.swift`
83
+- Views/Sidebar/: `PowerbankSidebarCardView.swift`, `SidebarPowerbanksSectionView.swift`
84
+- Source picker: `USB Meter/Views/Meter/Tabs/ChargeRecord/MeterChargeRecordTabView.swift`
85
+- Checkpoint editor: `USB Meter/Views/ChargedDevices/Sheets/ChargeSession/BatteryCheckpointEditorSheetView.swift`
+2 -0
Documentation/README.md
@@ -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`
+33 -1
USB Meter.xcodeproj/project.pbxproj
@@ -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;
+108 -2
USB Meter/Model/AppData.swift
@@ -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)
+1 -1
USB Meter/Model/CKModel.xcdatamodeld/.xccurrentversion
@@ -3,6 +3,6 @@
3 3
 <plist version="1.0">
4 4
 <dict>
5 5
 	<key>_XCCurrentVersionName</key>
6
-	<string>USB_Meter 18.xcdatamodel</string>
6
+	<string>USB_Meter 20.xcdatamodel</string>
7 7
 </dict>
8 8
 </plist>
+152 -0
USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 19.xcdatamodel/contents
@@ -0,0 +1,152 @@
1
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2
+<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23657" systemVersion="24G81" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES">
3
+    <entity name="ChargedDevice" representedClassName="ChargedDevice" syncable="YES" codeGenerationType="class">
4
+        <attribute name="id" optional="YES" attributeType="String"/>
5
+        <attribute name="name" optional="YES" attributeType="String"/>
6
+        <attribute name="deviceClassRawValue" optional="YES" attributeType="String"/>
7
+        <attribute name="deviceTemplateID" optional="YES" attributeType="String"/>
8
+        <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
9
+        <attribute name="chargingStateAvailabilityRawValue" optional="YES" attributeType="String"/>
10
+        <attribute name="supportsWiredCharging" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
11
+        <attribute name="supportsWirelessCharging" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
12
+        <attribute name="preferredChargingTransportRawValue" optional="YES" attributeType="String"/>
13
+        <attribute name="wirelessChargingProfileRawValue" optional="YES" attributeType="String"/>
14
+        <attribute name="configuredCompletionCurrentsRawValue" optional="YES" attributeType="String"/>
15
+        <attribute name="learnedCompletionCurrentsRawValue" optional="YES" attributeType="String"/>
16
+        <attribute name="wiredChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
17
+        <attribute name="wirelessChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
18
+        <attribute name="minimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
19
+        <attribute name="estimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
20
+        <attribute name="wiredMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
21
+        <attribute name="wirelessMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
22
+        <attribute name="wiredEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
23
+        <attribute name="wirelessEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
24
+        <attribute name="wirelessChargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
25
+        <attribute name="chargerObservedVoltageSelectionsRawValue" optional="YES" attributeType="String"/>
26
+        <attribute name="chargerIdleCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
27
+        <attribute name="chargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
28
+        <attribute name="chargerMaximumPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
29
+        <attribute name="qrIdentifier" optional="YES" attributeType="String"/>
30
+        <attribute name="notes" optional="YES" attributeType="String"/>
31
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
32
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
33
+    </entity>
34
+    <entity name="Powerbank" representedClassName="Powerbank" syncable="YES" codeGenerationType="class">
35
+        <attribute name="id" optional="YES" attributeType="String"/>
36
+        <attribute name="name" optional="YES" attributeType="String"/>
37
+        <attribute name="qrIdentifier" optional="YES" attributeType="String"/>
38
+        <attribute name="deviceTemplateID" optional="YES" attributeType="String"/>
39
+        <attribute name="notes" optional="YES" attributeType="String"/>
40
+        <attribute name="batteryLevelReportingRawValue" optional="YES" attributeType="String"/>
41
+        <attribute name="batteryBarsCount" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
42
+        <attribute name="estimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
43
+        <attribute name="apparentCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
44
+        <attribute name="configuredCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
45
+        <attribute name="learnedCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
46
+        <attribute name="minimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
47
+        <attribute name="sourceObservedVoltageSelectionsRawValue" optional="YES" attributeType="String"/>
48
+        <attribute name="sourceIdleCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
49
+        <attribute name="sourceMaximumPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
50
+        <attribute name="sourceEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
51
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
52
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
53
+    </entity>
54
+    <entity name="ChargeSession" representedClassName="ChargeSession" syncable="YES" codeGenerationType="class">
55
+        <attribute name="id" optional="YES" attributeType="String"/>
56
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
57
+        <attribute name="chargedPowerbankID" optional="YES" attributeType="String"/>
58
+        <attribute name="chargerID" optional="YES" attributeType="String"/>
59
+        <attribute name="sourcePowerbankID" optional="YES" attributeType="String"/>
60
+        <attribute name="meterMACAddress" optional="YES" attributeType="String"/>
61
+        <attribute name="meterName" optional="YES" attributeType="String"/>
62
+        <attribute name="meterModel" optional="YES" attributeType="String"/>
63
+        <attribute name="startedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
64
+        <attribute name="endedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
65
+        <attribute name="lastObservedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
66
+        <attribute name="pausedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
67
+        <attribute name="statusRawValue" optional="YES" attributeType="String"/>
68
+        <attribute name="sourceModeRawValue" optional="YES" attributeType="String"/>
69
+        <attribute name="chargingTransportRawValue" optional="YES" attributeType="String"/>
70
+        <attribute name="chargingStateRawValue" optional="YES" attributeType="String"/>
71
+        <attribute name="autoStopEnabled" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
72
+        <attribute name="selectedDataGroup" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
73
+        <attribute name="meterEnergyBaselineWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
74
+        <attribute name="meterChargeBaselineAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
75
+        <attribute name="meterDurationBaselineSeconds" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
76
+        <attribute name="meterLastEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
77
+        <attribute name="meterLastChargeAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
78
+        <attribute name="meterLastDurationSeconds" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
79
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
80
+        <attribute name="effectiveBatteryEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
81
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
82
+        <attribute name="minimumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
83
+        <attribute name="maximumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
84
+        <attribute name="maximumObservedPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
85
+        <attribute name="maximumObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
86
+        <attribute name="selectedSourceVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
87
+        <attribute name="completionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
88
+        <attribute name="stopThresholdAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
89
+        <attribute name="startBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
90
+        <attribute name="endBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
91
+        <attribute name="capacityEstimateWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
92
+        <attribute name="wirelessEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
93
+        <attribute name="usesEstimatedWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
94
+        <attribute name="shouldWarnAboutLowWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
95
+        <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
96
+        <attribute name="usedOfflineMeterCounters" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
97
+        <attribute name="belowThresholdSince" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
98
+        <attribute name="lastObservedCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
99
+        <attribute name="lastObservedPowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
100
+        <attribute name="lastObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
101
+        <attribute name="hasObservedChargeFlow" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
102
+        <attribute name="targetBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
103
+        <attribute name="targetBatteryAlertTriggeredAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
104
+        <attribute name="requiresCompletionConfirmation" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
105
+        <attribute name="completionConfirmationRequestedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
106
+        <attribute name="completionContradictionPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
107
+        <attribute name="completionConfirmationCooldownUntil" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
108
+        <attribute name="trimStart" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
109
+        <attribute name="trimEnd" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
110
+        <attribute name="wasConflictHealed" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
111
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
112
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
113
+    </entity>
114
+    <entity name="ChargeCheckpoint" representedClassName="ChargeCheckpoint" syncable="YES" codeGenerationType="class">
115
+        <attribute name="id" optional="YES" attributeType="String"/>
116
+        <attribute name="sessionID" optional="YES" attributeType="String"/>
117
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
118
+        <attribute name="powerbankID" optional="YES" attributeType="String"/>
119
+        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
120
+        <attribute name="batteryPercent" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
121
+        <attribute name="batteryBarsValue" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
122
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
123
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
124
+        <attribute name="currentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
125
+        <attribute name="voltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
126
+        <attribute name="label" optional="YES" attributeType="String"/>
127
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
128
+    </entity>
129
+    <entity name="ChargeSessionSample" representedClassName="ChargeSessionSample" syncable="NO" codeGenerationType="class">
130
+        <attribute name="id" optional="YES" attributeType="String"/>
131
+        <attribute name="sessionID" optional="YES" attributeType="String"/>
132
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
133
+        <attribute name="bucketIndex" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
134
+        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
135
+        <attribute name="averageCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
136
+        <attribute name="averageVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
137
+        <attribute name="averagePowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
138
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
139
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
140
+        <attribute name="estimatedBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
141
+        <attribute name="sampleCount" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
142
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
143
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
144
+    </entity>
145
+    <elements>
146
+        <element name="ChargedDevice" positionX="-72" positionY="-36" width="128" height="433"/>
147
+        <element name="Powerbank" positionX="-72" positionY="420" width="128" height="313"/>
148
+        <element name="ChargeSession" positionX="136" positionY="-36" width="128" height="913"/>
149
+        <element name="ChargeCheckpoint" positionX="344" positionY="-36" width="128" height="238"/>
150
+        <element name="ChargeSessionSample" positionX="552" positionY="-36" width="128" height="268"/>
151
+    </elements>
152
+</model>
+179 -0
USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 20.xcdatamodel/contents
@@ -0,0 +1,179 @@
1
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2
+<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23657" systemVersion="24G81" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES">
3
+    <entity name="ChargedDevice" representedClassName="ChargedDevice" syncable="YES" codeGenerationType="class">
4
+        <attribute name="id" optional="YES" attributeType="String"/>
5
+        <attribute name="name" optional="YES" attributeType="String"/>
6
+        <attribute name="deviceClassRawValue" optional="YES" attributeType="String"/>
7
+        <attribute name="deviceTemplateID" optional="YES" attributeType="String"/>
8
+        <attribute name="profileID" optional="YES" attributeType="String"/>
9
+        <attribute name="hasInternalSubject" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
10
+        <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
11
+        <attribute name="chargingStateAvailabilityRawValue" optional="YES" attributeType="String"/>
12
+        <attribute name="supportsWiredCharging" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
13
+        <attribute name="supportsWirelessCharging" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
14
+        <attribute name="preferredChargingTransportRawValue" optional="YES" attributeType="String"/>
15
+        <attribute name="wirelessChargingProfileRawValue" optional="YES" attributeType="String"/>
16
+        <attribute name="configuredCompletionCurrentsRawValue" optional="YES" attributeType="String"/>
17
+        <attribute name="learnedCompletionCurrentsRawValue" optional="YES" attributeType="String"/>
18
+        <attribute name="wiredChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
19
+        <attribute name="wirelessChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
20
+        <attribute name="minimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
21
+        <attribute name="estimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
22
+        <attribute name="wiredMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
23
+        <attribute name="wirelessMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
24
+        <attribute name="wiredEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
25
+        <attribute name="wirelessEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
26
+        <attribute name="wirelessChargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
27
+        <attribute name="chargerObservedVoltageSelectionsRawValue" optional="YES" attributeType="String"/>
28
+        <attribute name="chargerIdleCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
29
+        <attribute name="chargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
30
+        <attribute name="chargerMaximumPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
31
+        <attribute name="qrIdentifier" optional="YES" attributeType="String"/>
32
+        <attribute name="notes" optional="YES" attributeType="String"/>
33
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
34
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
35
+    </entity>
36
+    <entity name="Powerbank" representedClassName="Powerbank" syncable="YES" codeGenerationType="class">
37
+        <attribute name="id" optional="YES" attributeType="String"/>
38
+        <attribute name="name" optional="YES" attributeType="String"/>
39
+        <attribute name="qrIdentifier" optional="YES" attributeType="String"/>
40
+        <attribute name="deviceTemplateID" optional="YES" attributeType="String"/>
41
+        <attribute name="profileID" optional="YES" attributeType="String"/>
42
+        <attribute name="notes" optional="YES" attributeType="String"/>
43
+        <attribute name="batteryLevelReportingRawValue" optional="YES" attributeType="String"/>
44
+        <attribute name="batteryBarsCount" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
45
+        <attribute name="estimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
46
+        <attribute name="apparentCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
47
+        <attribute name="configuredCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
48
+        <attribute name="learnedCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
49
+        <attribute name="minimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
50
+        <attribute name="sourceObservedVoltageSelectionsRawValue" optional="YES" attributeType="String"/>
51
+        <attribute name="sourceIdleCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
52
+        <attribute name="sourceMaximumPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
53
+        <attribute name="sourceEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
54
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
55
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
56
+    </entity>
57
+    <entity name="DeviceProfile" representedClassName="DeviceProfile" syncable="YES" codeGenerationType="class">
58
+        <attribute name="id" optional="YES" attributeType="String"/>
59
+        <attribute name="name" optional="YES" attributeType="String"/>
60
+        <attribute name="categoryRawValue" optional="YES" attributeType="String"/>
61
+        <attribute name="iconSymbolName" optional="YES" attributeType="String"/>
62
+        <attribute name="iconFallbackSymbolName" optional="YES" attributeType="String"/>
63
+        <attribute name="isCustom" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
64
+        <attribute name="schemaVersion" optional="YES" attributeType="Integer 16" defaultValueString="1" usesScalarValueType="YES"/>
65
+        <attribute name="sortOrder" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
66
+        <attribute name="group" optional="YES" attributeType="String"/>
67
+        <attribute name="capWiredCharging" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
68
+        <attribute name="capWirelessCharging" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
69
+        <attribute name="capWirelessProfilesRawValue" optional="YES" attributeType="String"/>
70
+        <attribute name="capChargingStateAvailabilityRawValue" optional="YES" attributeType="String"/>
71
+        <attribute name="capHasInternalSubject" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
72
+        <attribute name="defaultWirelessChargingProfileRawValue" optional="YES" attributeType="String"/>
73
+        <attribute name="defaultWiredMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
74
+        <attribute name="defaultWirelessMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
75
+        <attribute name="defaultWiredEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
76
+        <attribute name="defaultWirelessEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
77
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
78
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
79
+    </entity>
80
+    <entity name="ChargeSession" representedClassName="ChargeSession" syncable="YES" codeGenerationType="class">
81
+        <attribute name="id" optional="YES" attributeType="String"/>
82
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
83
+        <attribute name="chargedPowerbankID" optional="YES" attributeType="String"/>
84
+        <attribute name="chargerID" optional="YES" attributeType="String"/>
85
+        <attribute name="sourcePowerbankID" optional="YES" attributeType="String"/>
86
+        <attribute name="meterMACAddress" optional="YES" attributeType="String"/>
87
+        <attribute name="meterName" optional="YES" attributeType="String"/>
88
+        <attribute name="meterModel" optional="YES" attributeType="String"/>
89
+        <attribute name="startedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
90
+        <attribute name="endedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
91
+        <attribute name="lastObservedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
92
+        <attribute name="pausedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
93
+        <attribute name="statusRawValue" optional="YES" attributeType="String"/>
94
+        <attribute name="sourceModeRawValue" optional="YES" attributeType="String"/>
95
+        <attribute name="chargingTransportRawValue" optional="YES" attributeType="String"/>
96
+        <attribute name="chargingStateRawValue" optional="YES" attributeType="String"/>
97
+        <attribute name="autoStopEnabled" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
98
+        <attribute name="selectedDataGroup" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
99
+        <attribute name="meterEnergyBaselineWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
100
+        <attribute name="meterChargeBaselineAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
101
+        <attribute name="meterDurationBaselineSeconds" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
102
+        <attribute name="meterLastEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
103
+        <attribute name="meterLastChargeAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
104
+        <attribute name="meterLastDurationSeconds" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
105
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
106
+        <attribute name="effectiveBatteryEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
107
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
108
+        <attribute name="minimumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
109
+        <attribute name="maximumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
110
+        <attribute name="maximumObservedPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
111
+        <attribute name="maximumObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
112
+        <attribute name="selectedSourceVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
113
+        <attribute name="completionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
114
+        <attribute name="stopThresholdAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
115
+        <attribute name="startBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
116
+        <attribute name="endBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
117
+        <attribute name="capacityEstimateWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
118
+        <attribute name="wirelessEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
119
+        <attribute name="usesEstimatedWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
120
+        <attribute name="shouldWarnAboutLowWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
121
+        <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
122
+        <attribute name="usedOfflineMeterCounters" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
123
+        <attribute name="belowThresholdSince" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
124
+        <attribute name="lastObservedCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
125
+        <attribute name="lastObservedPowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
126
+        <attribute name="lastObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
127
+        <attribute name="hasObservedChargeFlow" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
128
+        <attribute name="targetBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
129
+        <attribute name="targetBatteryAlertTriggeredAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
130
+        <attribute name="requiresCompletionConfirmation" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
131
+        <attribute name="completionConfirmationRequestedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
132
+        <attribute name="completionContradictionPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
133
+        <attribute name="completionConfirmationCooldownUntil" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
134
+        <attribute name="trimStart" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
135
+        <attribute name="trimEnd" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
136
+        <attribute name="wasConflictHealed" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
137
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
138
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
139
+    </entity>
140
+    <entity name="ChargeCheckpoint" representedClassName="ChargeCheckpoint" syncable="YES" codeGenerationType="class">
141
+        <attribute name="id" optional="YES" attributeType="String"/>
142
+        <attribute name="sessionID" optional="YES" attributeType="String"/>
143
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
144
+        <attribute name="powerbankID" optional="YES" attributeType="String"/>
145
+        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
146
+        <attribute name="batteryPercent" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
147
+        <attribute name="batteryBarsValue" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
148
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
149
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
150
+        <attribute name="currentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
151
+        <attribute name="voltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
152
+        <attribute name="label" optional="YES" attributeType="String"/>
153
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
154
+    </entity>
155
+    <entity name="ChargeSessionSample" representedClassName="ChargeSessionSample" syncable="NO" codeGenerationType="class">
156
+        <attribute name="id" optional="YES" attributeType="String"/>
157
+        <attribute name="sessionID" optional="YES" attributeType="String"/>
158
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
159
+        <attribute name="bucketIndex" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
160
+        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
161
+        <attribute name="averageCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
162
+        <attribute name="averageVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
163
+        <attribute name="averagePowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
164
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
165
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
166
+        <attribute name="estimatedBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
167
+        <attribute name="sampleCount" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
168
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
169
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
170
+    </entity>
171
+    <elements>
172
+        <element name="ChargedDevice" positionX="-72" positionY="-36" width="128" height="463"/>
173
+        <element name="Powerbank" positionX="-72" positionY="450" width="128" height="328"/>
174
+        <element name="DeviceProfile" positionX="-288" positionY="-36" width="160" height="358"/>
175
+        <element name="ChargeSession" positionX="136" positionY="-36" width="128" height="913"/>
176
+        <element name="ChargeCheckpoint" positionX="344" positionY="-36" width="128" height="238"/>
177
+        <element name="ChargeSessionSample" positionX="552" positionY="-36" width="128" height="268"/>
178
+    </elements>
179
+</model>
+439 -0
USB Meter/Model/ChargeInsightsModel.swift
@@ -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
+}
+737 -22
USB Meter/Model/ChargeInsightsStore.swift
@@ -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) {
+256 -0
USB Meter/Templates/DeviceProfilesCatalog.json
@@ -0,0 +1,256 @@
1
+{
2
+  "profiles": [
3
+    {
4
+      "id": "apple-iphone",
5
+      "name": "iPhone",
6
+      "group": "Apple",
7
+      "category": "phone",
8
+      "icon": { "type": "systemSymbol", "name": "iphone", "fallbackSystemName": "smartphone" },
9
+      "sortOrder": 10,
10
+      "capWiredCharging": true,
11
+      "capWirelessCharging": true,
12
+      "capWirelessProfiles": ["magsafe", "genericQi"],
13
+      "capChargingStateAvailability": "onOrOff",
14
+      "capHasInternalSubject": false,
15
+      "defaultWirelessChargingProfile": "magsafe"
16
+    },
17
+    {
18
+      "id": "apple-ipad",
19
+      "name": "iPad",
20
+      "group": "Apple",
21
+      "category": "tablet",
22
+      "icon": { "type": "systemSymbol", "name": "ipad", "fallbackSystemName": "rectangle" },
23
+      "sortOrder": 20,
24
+      "capWiredCharging": true,
25
+      "capWirelessCharging": false,
26
+      "capWirelessProfiles": [],
27
+      "capChargingStateAvailability": "onOrOff",
28
+      "capHasInternalSubject": false,
29
+      "defaultWirelessChargingProfile": null
30
+    },
31
+    {
32
+      "id": "apple-watch",
33
+      "name": "Apple Watch",
34
+      "group": "Apple",
35
+      "category": "watch",
36
+      "icon": { "type": "systemSymbol", "name": "applewatch", "fallbackSystemName": "watch.analog" },
37
+      "sortOrder": 30,
38
+      "capWiredCharging": false,
39
+      "capWirelessCharging": true,
40
+      "capWirelessProfiles": ["genericQi"],
41
+      "capChargingStateAvailability": "onOnly",
42
+      "capHasInternalSubject": false,
43
+      "defaultWirelessChargingProfile": "genericQi"
44
+    },
45
+    {
46
+      "id": "apple-airpods",
47
+      "name": "AirPods",
48
+      "group": "Apple",
49
+      "category": "audioAccessory",
50
+      "icon": { "type": "systemSymbol", "name": "airpods", "fallbackSystemName": "earbuds.case" },
51
+      "sortOrder": 40,
52
+      "capWiredCharging": true,
53
+      "capWirelessCharging": true,
54
+      "capWirelessProfiles": ["genericQi"],
55
+      "capChargingStateAvailability": "onOnly",
56
+      "capHasInternalSubject": false,
57
+      "defaultWirelessChargingProfile": "genericQi"
58
+    },
59
+    {
60
+      "id": "apple-airpods-case",
61
+      "name": "AirPods Case",
62
+      "group": "Apple",
63
+      "category": "accessoryCase",
64
+      "icon": { "type": "systemSymbol", "name": "airpods.case.fill", "fallbackSystemName": "earbuds.case" },
65
+      "sortOrder": 50,
66
+      "capWiredCharging": true,
67
+      "capWirelessCharging": true,
68
+      "capWirelessProfiles": ["magsafe", "genericQi"],
69
+      "capChargingStateAvailability": "onOnly",
70
+      "capHasInternalSubject": true,
71
+      "defaultWirelessChargingProfile": "genericQi"
72
+    },
73
+    {
74
+      "id": "apple-pencil",
75
+      "name": "Apple Pencil",
76
+      "group": "Apple",
77
+      "category": "accessoryCase",
78
+      "icon": { "type": "systemSymbol", "name": "applepencil", "fallbackSystemName": "pencil" },
79
+      "sortOrder": 60,
80
+      "capWiredCharging": true,
81
+      "capWirelessCharging": true,
82
+      "capWirelessProfiles": ["genericQi"],
83
+      "capChargingStateAvailability": "onOnly",
84
+      "capHasInternalSubject": false,
85
+      "defaultWirelessChargingProfile": "genericQi"
86
+    },
87
+    {
88
+      "id": "generic-phone",
89
+      "name": "Phone",
90
+      "group": "Generic",
91
+      "category": "phone",
92
+      "icon": { "type": "systemSymbol", "name": "smartphone", "fallbackSystemName": "rectangle.portrait" },
93
+      "sortOrder": 110,
94
+      "capWiredCharging": true,
95
+      "capWirelessCharging": true,
96
+      "capWirelessProfiles": ["genericQi"],
97
+      "capChargingStateAvailability": "onOrOff",
98
+      "capHasInternalSubject": false,
99
+      "defaultWirelessChargingProfile": "genericQi"
100
+    },
101
+    {
102
+      "id": "generic-tablet",
103
+      "name": "Tablet",
104
+      "group": "Generic",
105
+      "category": "tablet",
106
+      "icon": { "type": "systemSymbol", "name": "rectangle", "fallbackSystemName": "rectangle" },
107
+      "sortOrder": 120,
108
+      "capWiredCharging": true,
109
+      "capWirelessCharging": false,
110
+      "capWirelessProfiles": [],
111
+      "capChargingStateAvailability": "onOrOff",
112
+      "capHasInternalSubject": false,
113
+      "defaultWirelessChargingProfile": null
114
+    },
115
+    {
116
+      "id": "generic-watch",
117
+      "name": "Watch",
118
+      "group": "Generic",
119
+      "category": "watch",
120
+      "icon": { "type": "systemSymbol", "name": "watch.analog", "fallbackSystemName": "clock" },
121
+      "sortOrder": 130,
122
+      "capWiredCharging": false,
123
+      "capWirelessCharging": true,
124
+      "capWirelessProfiles": ["genericQi"],
125
+      "capChargingStateAvailability": "onOnly",
126
+      "capHasInternalSubject": false,
127
+      "defaultWirelessChargingProfile": "genericQi"
128
+    },
129
+    {
130
+      "id": "generic-laptop",
131
+      "name": "Laptop",
132
+      "group": "Generic",
133
+      "category": "laptop",
134
+      "icon": { "type": "systemSymbol", "name": "laptopcomputer", "fallbackSystemName": "display" },
135
+      "sortOrder": 140,
136
+      "capWiredCharging": true,
137
+      "capWirelessCharging": false,
138
+      "capWirelessProfiles": [],
139
+      "capChargingStateAvailability": "onOrOff",
140
+      "capHasInternalSubject": false,
141
+      "defaultWirelessChargingProfile": null
142
+    },
143
+    {
144
+      "id": "generic-audio-accessory",
145
+      "name": "Audio Accessory",
146
+      "group": "Generic",
147
+      "category": "audioAccessory",
148
+      "icon": { "type": "systemSymbol", "name": "earbuds.case", "fallbackSystemName": "headphones" },
149
+      "sortOrder": 160,
150
+      "capWiredCharging": true,
151
+      "capWirelessCharging": true,
152
+      "capWirelessProfiles": ["genericQi"],
153
+      "capChargingStateAvailability": "onOnly",
154
+      "capHasInternalSubject": false,
155
+      "defaultWirelessChargingProfile": "genericQi"
156
+    },
157
+    {
158
+      "id": "generic-charging-case",
159
+      "name": "Charging Case",
160
+      "group": "Generic",
161
+      "category": "accessoryCase",
162
+      "icon": { "type": "systemSymbol", "name": "earbuds.case", "fallbackSystemName": "earbuds.case" },
163
+      "sortOrder": 165,
164
+      "capWiredCharging": true,
165
+      "capWirelessCharging": true,
166
+      "capWirelessProfiles": ["genericQi", "magsafe"],
167
+      "capChargingStateAvailability": "onOnly",
168
+      "capHasInternalSubject": true,
169
+      "defaultWirelessChargingProfile": "genericQi"
170
+    },
171
+    {
172
+      "id": "generic-device",
173
+      "name": "Other Device",
174
+      "group": "Generic",
175
+      "category": "other",
176
+      "icon": { "type": "systemSymbol", "name": "shippingbox", "fallbackSystemName": "shippingbox" },
177
+      "sortOrder": 170,
178
+      "capWiredCharging": true,
179
+      "capWirelessCharging": false,
180
+      "capWirelessProfiles": [],
181
+      "capChargingStateAvailability": "onOnly",
182
+      "capHasInternalSubject": false,
183
+      "defaultWirelessChargingProfile": null
184
+    },
185
+    {
186
+      "id": "apple-magsafe-charger",
187
+      "name": "Apple MagSafe Charger",
188
+      "group": "Apple",
189
+      "category": "charger",
190
+      "icon": { "type": "systemSymbol", "name": "magsafe.batterypack", "fallbackSystemName": "bolt.circle" },
191
+      "sortOrder": 210,
192
+      "capWiredCharging": false,
193
+      "capWirelessCharging": true,
194
+      "capWirelessProfiles": ["magsafe"],
195
+      "capChargingStateAvailability": "onOnly",
196
+      "capHasInternalSubject": false,
197
+      "defaultWirelessChargingProfile": "magsafe"
198
+    },
199
+    {
200
+      "id": "apple-watch-charger",
201
+      "name": "Apple Watch Charger",
202
+      "group": "Apple",
203
+      "category": "charger",
204
+      "icon": { "type": "systemSymbol", "name": "applewatch.radiowaves.left.and.right", "fallbackSystemName": "bolt.circle" },
205
+      "sortOrder": 220,
206
+      "capWiredCharging": false,
207
+      "capWirelessCharging": true,
208
+      "capWirelessProfiles": ["genericQi"],
209
+      "capChargingStateAvailability": "onOnly",
210
+      "capHasInternalSubject": false,
211
+      "defaultWirelessChargingProfile": "genericQi"
212
+    },
213
+    {
214
+      "id": "generic-magsafe-charger",
215
+      "name": "Generic MagSafe Charger",
216
+      "group": "Generic",
217
+      "category": "charger",
218
+      "icon": { "type": "systemSymbol", "name": "bolt.circle", "fallbackSystemName": "bolt.circle" },
219
+      "sortOrder": 230,
220
+      "capWiredCharging": false,
221
+      "capWirelessCharging": true,
222
+      "capWirelessProfiles": ["magsafe"],
223
+      "capChargingStateAvailability": "onOnly",
224
+      "capHasInternalSubject": false,
225
+      "defaultWirelessChargingProfile": "magsafe"
226
+    },
227
+    {
228
+      "id": "generic-qi-charger",
229
+      "name": "Generic Qi Charger",
230
+      "group": "Generic",
231
+      "category": "charger",
232
+      "icon": { "type": "systemSymbol", "name": "bolt.horizontal.circle", "fallbackSystemName": "bolt.horizontal.circle" },
233
+      "sortOrder": 240,
234
+      "capWiredCharging": false,
235
+      "capWirelessCharging": true,
236
+      "capWirelessProfiles": ["genericQi"],
237
+      "capChargingStateAvailability": "onOnly",
238
+      "capHasInternalSubject": false,
239
+      "defaultWirelessChargingProfile": "genericQi"
240
+    },
241
+    {
242
+      "id": "generic-powerbank",
243
+      "name": "Powerbank",
244
+      "group": "Generic",
245
+      "category": "powerbank",
246
+      "icon": { "type": "systemSymbol", "name": "battery.100.bolt", "fallbackSystemName": "battery.100.bolt" },
247
+      "sortOrder": 310,
248
+      "capWiredCharging": true,
249
+      "capWirelessCharging": false,
250
+      "capWirelessProfiles": [],
251
+      "capChargingStateAvailability": "offOnly",
252
+      "capHasInternalSubject": false,
253
+      "defaultWirelessChargingProfile": null
254
+    }
255
+  ]
256
+}
+7 -0
USB Meter/Views/ChargedDevices/Details/ChargedDeviceSettingsView.swift
@@ -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
 
+94 -7
USB Meter/Views/ChargedDevices/Sheets/ChargeSession/BatteryCheckpointEditorSheetView.swift
@@ -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
         }
+270 -143
USB Meter/Views/ChargedDevices/Sheets/Editors/ChargedDeviceEditorSheetView.swift
@@ -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
 }
+90 -8
USB Meter/Views/Meter/Tabs/ChargeRecord/MeterChargeRecordTabView.swift
@@ -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,
+162 -0
USB Meter/Views/Powerbanks/PowerbankDetailView.swift
@@ -0,0 +1,162 @@
1
+//
2
+//  PowerbankDetailView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct PowerbankDetailView: View {
9
+    @EnvironmentObject private var appData: AppData
10
+    @Environment(\.dismiss) private var dismiss
11
+
12
+    let powerbankID: UUID
13
+
14
+    @State private var isEditing = false
15
+    @State private var pendingDeletion = false
16
+
17
+    private var powerbank: PowerbankSummary? {
18
+        appData.powerbankSummaries.first { $0.id == powerbankID }
19
+    }
20
+
21
+    var body: some View {
22
+        Group {
23
+            if let powerbank {
24
+                content(for: powerbank)
25
+            } else {
26
+                Text("Powerbank not found.")
27
+                    .foregroundColor(.secondary)
28
+            }
29
+        }
30
+        .navigationTitle(powerbank?.name ?? "Powerbank")
31
+        .navigationBarTitleDisplayMode(.inline)
32
+        .toolbar {
33
+            ToolbarItem(placement: .navigationBarTrailing) {
34
+                Menu {
35
+                    Button("Edit") { isEditing = true }
36
+                    Button("Delete", role: .destructive) { pendingDeletion = true }
37
+                } label: {
38
+                    Image(systemName: "ellipsis.circle")
39
+                }
40
+                .disabled(powerbank == nil)
41
+            }
42
+        }
43
+        .sheet(isPresented: $isEditing) {
44
+            if let powerbank {
45
+                PowerbankEditorSheetView(powerbank: powerbank)
46
+                    .environmentObject(appData)
47
+            }
48
+        }
49
+        .confirmationDialog(
50
+            "Delete \(powerbank?.name ?? "powerbank")?",
51
+            isPresented: $pendingDeletion,
52
+            titleVisibility: .visible
53
+        ) {
54
+            Button("Delete", role: .destructive) {
55
+                if let powerbank {
56
+                    _ = appData.deletePowerbank(id: powerbank.id)
57
+                    dismiss()
58
+                }
59
+            }
60
+            Button("Cancel", role: .cancel) {}
61
+        } message: {
62
+            Text("This will permanently remove the powerbank and any sessions where it is the subject. Sessions where it was the source will keep their data, with the source link cleared.")
63
+        }
64
+    }
65
+
66
+    @ViewBuilder
67
+    private func content(for powerbank: PowerbankSummary) -> some View {
68
+        Form {
69
+            Section(header: Text("Identity")) {
70
+                row("Name", value: powerbank.name)
71
+                HStack {
72
+                    Text("QR identifier")
73
+                    Spacer()
74
+                    Text(powerbank.qrIdentifier)
75
+                        .font(.caption.monospaced())
76
+                        .foregroundColor(.secondary)
77
+                        .textSelection(.enabled)
78
+                }
79
+            }
80
+
81
+            Section(header: Text("Battery")) {
82
+                row("Reporting", value: powerbank.batteryLevelReporting.title)
83
+                if powerbank.batteryLevelReporting == .bars {
84
+                    row("Bars resolution", value: "\(powerbank.batteryBarsCount)")
85
+                }
86
+                if let estimatedCapacityWh = powerbank.estimatedBatteryCapacityWh {
87
+                    row("Capacity (charged)", value: "\(estimatedCapacityWh.format(decimalDigits: 2)) Wh")
88
+                }
89
+                if let apparentCapacityWh = powerbank.apparentCapacityWh {
90
+                    row("Apparent capacity (delivered)", value: "\(apparentCapacityWh.format(decimalDigits: 2)) Wh")
91
+                }
92
+                if let efficiency = powerbank.sourceEfficiencyFactor {
93
+                    row("Efficiency", value: "\((efficiency * 100).format(decimalDigits: 1))%")
94
+                }
95
+            }
96
+
97
+            Section(header: Text("Source statistics")) {
98
+                if powerbank.sessionsAsSource.isEmpty {
99
+                    Text("No discharge sessions recorded yet.")
100
+                        .font(.caption)
101
+                        .foregroundColor(.secondary)
102
+                } else {
103
+                    row("Sessions as source", value: "\(powerbank.sessionsAsSource.count)")
104
+                    row("Total Wh delivered", value: "\(powerbank.totalDeliveredEnergyWh.format(decimalDigits: 2)) Wh")
105
+                    if let maxPower = powerbank.sourceMaximumPowerWatts {
106
+                        row("Max power", value: "\(maxPower.format(decimalDigits: 2)) W")
107
+                    }
108
+                    if powerbank.sourceVoltageMaxCurrents.isEmpty == false {
109
+                        VStack(alignment: .leading, spacing: 4) {
110
+                            Text("Voltage profile")
111
+                                .font(.subheadline.weight(.semibold))
112
+                            ForEach(powerbank.sourceVoltageMaxCurrents.keys.sorted(), id: \.self) { voltage in
113
+                                let maxAmps = powerbank.sourceVoltageMaxCurrents[voltage] ?? 0
114
+                                HStack {
115
+                                    Text(String(format: "%.1f V", voltage))
116
+                                        .font(.caption.monospaced())
117
+                                    Spacer()
118
+                                    Text("max \(maxAmps.format(decimalDigits: 2)) A")
119
+                                        .font(.caption)
120
+                                        .foregroundColor(.secondary)
121
+                                }
122
+                            }
123
+                        }
124
+                    } else if powerbank.sourceObservedVoltageSelections.isEmpty == false {
125
+                        row(
126
+                            "Observed voltages",
127
+                            value: powerbank.sourceObservedVoltageSelections
128
+                                .map { String(format: "%.1fV", $0) }
129
+                                .joined(separator: ", ")
130
+                        )
131
+                    }
132
+                }
133
+            }
134
+
135
+            Section(header: Text("Charging history")) {
136
+                if powerbank.sessionsAsSubject.isEmpty {
137
+                    Text("Not charged yet.")
138
+                        .font(.caption)
139
+                        .foregroundColor(.secondary)
140
+                } else {
141
+                    row("Charge sessions", value: "\(powerbank.sessionsAsSubject.count)")
142
+                    row("Total Wh received", value: "\(powerbank.totalReceivedEnergyWh.format(decimalDigits: 2)) Wh")
143
+                }
144
+            }
145
+
146
+            if let notes = powerbank.notes, !notes.isEmpty {
147
+                Section(header: Text("Notes")) {
148
+                    Text(notes)
149
+                }
150
+            }
151
+        }
152
+    }
153
+
154
+    private func row(_ label: String, value: String) -> some View {
155
+        HStack {
156
+            Text(label)
157
+            Spacer()
158
+            Text(value)
159
+                .foregroundColor(.secondary)
160
+        }
161
+    }
162
+}
+121 -0
USB Meter/Views/Powerbanks/PowerbankEditorSheetView.swift
@@ -0,0 +1,121 @@
1
+//
2
+//  PowerbankEditorSheetView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct PowerbankEditorSheetView: View {
9
+    @EnvironmentObject private var appData: AppData
10
+    @Environment(\.dismiss) private var dismiss
11
+
12
+    let powerbank: PowerbankSummary?
13
+    let standalone: Bool
14
+
15
+    @State private var name: String
16
+    @State private var batteryLevelReporting: BatteryLevelReporting
17
+    @State private var batteryBarsCount: Int
18
+    @State private var notes: String
19
+
20
+    init(
21
+        powerbank: PowerbankSummary? = nil,
22
+        standalone: Bool = true
23
+    ) {
24
+        self.powerbank = powerbank
25
+        self.standalone = standalone
26
+        _name = State(initialValue: powerbank?.name ?? "")
27
+        _batteryLevelReporting = State(initialValue: powerbank?.batteryLevelReporting ?? .percent)
28
+        _batteryBarsCount = State(initialValue: powerbank?.batteryBarsCount ?? 4)
29
+        _notes = State(initialValue: powerbank?.notes ?? "")
30
+    }
31
+
32
+    var body: some View {
33
+        ChargedDeviceEditorScaffoldView(
34
+            title: editorTitle,
35
+            saveButtonTitle: saveButtonTitle,
36
+            canSave: canSave,
37
+            standalone: standalone,
38
+            save: save
39
+        ) {
40
+            Section(header: Text("Identity")) {
41
+                TextField("Powerbank name", text: $name)
42
+
43
+                if let powerbank {
44
+                    Text(powerbank.qrIdentifier)
45
+                        .font(.caption.monospaced())
46
+                        .foregroundColor(.secondary)
47
+                        .textSelection(.enabled)
48
+                }
49
+            }
50
+
51
+            Section(
52
+                header: ContextInfoHeader(
53
+                    title: "Battery Level Reporting",
54
+                    message: "Powerbanks report battery in different ways: 0–100%, discrete bars (e.g. 4 of 4), a single LED that lights only when full, or not at all. This selection drives how checkpoints are entered and how capacity is learned."
55
+                )
56
+            ) {
57
+                Picker("Reporting", selection: $batteryLevelReporting) {
58
+                    ForEach(BatteryLevelReporting.allCases) { reporting in
59
+                        Text(reporting.title).tag(reporting)
60
+                    }
61
+                }
62
+                .pickerStyle(.menu)
63
+
64
+                if batteryLevelReporting == .bars {
65
+                    Stepper(value: $batteryBarsCount, in: 1...10) {
66
+                        Text("Bars resolution: \(batteryBarsCount)")
67
+                    }
68
+                }
69
+
70
+                Text(batteryLevelReporting.description)
71
+                    .font(.caption)
72
+                    .foregroundColor(.secondary)
73
+            }
74
+
75
+            Section(header: Text("Notes")) {
76
+                TextField("Optional notes", text: $notes)
77
+            }
78
+        }
79
+    }
80
+
81
+    private var editorTitle: String {
82
+        powerbank == nil ? "New Powerbank" : "Edit Powerbank"
83
+    }
84
+
85
+    private var saveButtonTitle: String {
86
+        powerbank == nil ? "Save" : "Update"
87
+    }
88
+
89
+    private var canSave: Bool {
90
+        !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
91
+    }
92
+
93
+    private func save() {
94
+        let trimmedNotes = notes.trimmingCharacters(in: .whitespacesAndNewlines)
95
+        let notesValue: String? = trimmedNotes.isEmpty ? nil : trimmedNotes
96
+
97
+        let didSave: Bool
98
+        if let powerbank {
99
+            didSave = appData.updatePowerbank(
100
+                id: powerbank.id,
101
+                name: name,
102
+                templateID: powerbank.deviceTemplateID,
103
+                batteryLevelReporting: batteryLevelReporting,
104
+                batteryBarsCount: batteryBarsCount,
105
+                notes: notesValue
106
+            )
107
+        } else {
108
+            didSave = appData.createPowerbank(
109
+                name: name,
110
+                templateID: nil,
111
+                batteryLevelReporting: batteryLevelReporting,
112
+                batteryBarsCount: batteryBarsCount,
113
+                notes: notesValue
114
+            )
115
+        }
116
+
117
+        if didSave {
118
+            dismiss()
119
+        }
120
+    }
121
+}
+69 -0
USB Meter/Views/Sidebar/PowerbankSidebarCardView.swift
@@ -0,0 +1,69 @@
1
+//
2
+//  PowerbankSidebarCardView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct PowerbankSidebarCardView: View {
9
+    let powerbank: PowerbankSummary
10
+
11
+    var body: some View {
12
+        HStack(alignment: .top, spacing: 12) {
13
+            ChargedDeviceQRCodeView(qrIdentifier: powerbank.qrIdentifier, side: 54)
14
+
15
+            VStack(alignment: .leading, spacing: 6) {
16
+                header
17
+                Text(powerbank.identityTitle)
18
+                    .font(.caption.weight(.semibold))
19
+                    .foregroundColor(.secondary)
20
+                details
21
+            }
22
+        }
23
+        .padding(.vertical, 4)
24
+    }
25
+
26
+    private var header: some View {
27
+        HStack {
28
+            Label(powerbank.name, systemImage: powerbank.identitySymbolName)
29
+                .font(.headline)
30
+
31
+            if powerbank.openSession != nil {
32
+                Spacer()
33
+                Text("Live")
34
+                    .font(.caption.weight(.bold))
35
+                    .foregroundColor(.green)
36
+            }
37
+        }
38
+    }
39
+
40
+    @ViewBuilder
41
+    private var details: some View {
42
+        Text(reportingSummary)
43
+            .font(.caption2)
44
+            .foregroundColor(.secondary)
45
+
46
+        if let capacityWh = powerbank.apparentCapacityWh ?? powerbank.estimatedBatteryCapacityWh {
47
+            Text("Capacity: \(capacityWh.format(decimalDigits: 2)) Wh")
48
+                .font(.caption2)
49
+                .foregroundColor(.secondary)
50
+        } else {
51
+            Text("Capacity: learning")
52
+                .font(.caption2)
53
+                .foregroundColor(.secondary)
54
+        }
55
+    }
56
+
57
+    private var reportingSummary: String {
58
+        switch powerbank.batteryLevelReporting {
59
+        case .percent:
60
+            return "Battery: 0–100%"
61
+        case .bars:
62
+            return "Battery: \(powerbank.batteryBarsCount) bars"
63
+        case .fullOnly:
64
+            return "Battery: full-only LED"
65
+        case .none:
66
+            return "Battery: not reported"
67
+        }
68
+    }
69
+}
+67 -0
USB Meter/Views/Sidebar/SidebarPowerbanksSectionView.swift
@@ -0,0 +1,67 @@
1
+//
2
+//  SidebarPowerbanksSectionView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct SidebarPowerbanksSectionView: View {
9
+    let title: String
10
+    let powerbanks: [PowerbankSummary]
11
+    let emptyStateText: String
12
+    let tint: Color
13
+    let isExpanded: Bool
14
+    let onToggle: () -> Void
15
+    let onAdd: () -> Void
16
+
17
+    var body: some View {
18
+        Section(header: headerView) {
19
+            if isExpanded {
20
+                ForEach(powerbanks) { powerbank in
21
+                    NavigationLink(destination: PowerbankDetailView(powerbankID: powerbank.id)) {
22
+                        PowerbankSidebarCardView(powerbank: powerbank)
23
+                    }
24
+                    .buttonStyle(.plain)
25
+                    .transition(.opacity.combined(with: .move(edge: .top)))
26
+                }
27
+
28
+                if powerbanks.isEmpty {
29
+                    Text(emptyStateText)
30
+                        .font(.caption)
31
+                        .foregroundColor(.secondary)
32
+                        .padding(.vertical, 6)
33
+                        .transition(.opacity)
34
+                }
35
+            }
36
+        }
37
+    }
38
+
39
+    private var headerView: some View {
40
+        HStack(alignment: .firstTextBaseline, spacing: 10) {
41
+            Button(action: onToggle) {
42
+                HStack(alignment: .firstTextBaseline, spacing: 4) {
43
+                    Image(systemName: "chevron.right")
44
+                        .font(.caption.weight(.semibold))
45
+                        .foregroundColor(.secondary)
46
+                        .rotationEffect(.degrees(isExpanded ? 90 : 0))
47
+                        .animation(.easeInOut(duration: 0.22), value: isExpanded)
48
+                    Text(title)
49
+                        .font(.headline)
50
+                }
51
+            }
52
+            .buttonStyle(.plain)
53
+            Spacer()
54
+            Button(action: onAdd) {
55
+                Image(systemName: "plus.circle.fill")
56
+                    .font(.body.weight(.semibold))
57
+                    .foregroundColor(tint)
58
+            }
59
+            .buttonStyle(.plain)
60
+            Text("\(powerbanks.count)")
61
+                .font(.caption.weight(.bold))
62
+                .padding(.horizontal, 10)
63
+                .padding(.vertical, 6)
64
+                .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
65
+        }
66
+    }
67
+}
+21 -0
USB Meter/Views/Sidebar/SidebarView.swift
@@ -10,6 +10,7 @@ private enum SidebarCreationSheet: Identifiable {
10 10
     case meter
11 11
     case device
12 12
     case charger
13
+    case powerbank
13 14
 
14 15
     var id: String {
15 16
         switch self {
@@ -19,6 +20,8 @@ private enum SidebarCreationSheet: Identifiable {
19 20
             return "device"
20 21
         case .charger:
21 22
             return "charger"
23
+        case .powerbank:
24
+            return "powerbank"
22 25
         }
23 26
     }
24 27
 }
@@ -28,6 +31,7 @@ struct SidebarView: View {
28 31
     @State private var isUSBMetersExpanded = true
29 32
     @State private var isDevicesExpanded = true
30 33
     @State private var isChargersExpanded = true
34
+    @State private var isPowerbanksExpanded = true
31 35
     @State private var isHelpExpanded = false
32 36
     @State private var dismissedAutoHelpReason: SidebarHelpReason?
33 37
     @State private var now = Date()
@@ -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