|
Bogdan Timofte
authored
a month ago
|
1
|
//
|
|
|
2
|
// ConsumptionMonitorView.swift
|
|
|
3
|
// USB Meter
|
|
|
4
|
//
|
|
|
5
|
|
|
|
6
|
import SwiftUI
|
|
|
7
|
import Charts
|
|
|
8
|
|
|
|
9
|
// MARK: - Shared helpers (file-private)
|
|
|
10
|
|
|
|
11
|
private func formattedDuration(_ duration: TimeInterval) -> String {
|
|
|
12
|
let formatter = DateComponentsFormatter()
|
|
|
13
|
formatter.allowedUnits = duration >= 3600 ? [.hour, .minute] : [.minute, .second]
|
|
|
14
|
formatter.unitsStyle = .abbreviated
|
|
|
15
|
formatter.zeroFormattingBehavior = .pad
|
|
|
16
|
return formatter.string(from: max(duration, 0)) ?? "0m"
|
|
|
17
|
}
|
|
|
18
|
|
|
|
19
|
private func energyLabel(_ wattHours: Double) -> String {
|
|
|
20
|
wattHours >= 1000
|
|
|
21
|
? "\((wattHours / 1000).format(decimalDigits: 3)) kWh"
|
|
|
22
|
: "\(wattHours.format(decimalDigits: 2)) Wh"
|
|
|
23
|
}
|
|
|
24
|
|
|
|
25
|
@available(iOS 16, *)
|
|
|
26
|
private func consumptionChart(samples: [ConsumptionMonitorSample], tint: Color) -> some View {
|
|
|
27
|
let duration = samples.last.map { $0.timestamp.timeIntervalSince(samples[0].timestamp) } ?? 0
|
|
|
28
|
return Chart(samples) { sample in
|
|
|
29
|
LineMark(
|
|
|
30
|
x: .value("Time", sample.timestamp),
|
|
|
31
|
y: .value("W", sample.averagePowerWatts)
|
|
|
32
|
)
|
|
|
33
|
.foregroundStyle(tint)
|
|
|
34
|
.interpolationMethod(.catmullRom)
|
|
|
35
|
}
|
|
|
36
|
.frame(height: 140)
|
|
|
37
|
.chartYScale(domain: .automatic(includesZero: false))
|
|
|
38
|
.chartXAxis {
|
|
|
39
|
if duration > 3600 {
|
|
|
40
|
AxisMarks(values: .stride(by: .hour)) { _ in
|
|
|
41
|
AxisGridLine()
|
|
|
42
|
AxisValueLabel(format: .dateTime.hour())
|
|
|
43
|
}
|
|
|
44
|
} else {
|
|
|
45
|
AxisMarks(values: .stride(by: .minute, count: 10)) { _ in
|
|
|
46
|
AxisGridLine()
|
|
|
47
|
AxisValueLabel(format: .dateTime.hour(.defaultDigits(amPM: .omitted)).minute())
|
|
|
48
|
}
|
|
|
49
|
}
|
|
|
50
|
}
|
|
|
51
|
.chartYAxis {
|
|
|
52
|
AxisMarks { value in
|
|
|
53
|
AxisGridLine()
|
|
|
54
|
AxisValueLabel {
|
|
|
55
|
if let v = value.as(Double.self) {
|
|
|
56
|
Text("\(v.format(decimalDigits: 1)) W")
|
|
|
57
|
}
|
|
|
58
|
}
|
|
|
59
|
}
|
|
|
60
|
}
|
|
|
61
|
}
|
|
|
62
|
|
|
|
63
|
// MARK: - Main View
|
|
|
64
|
|
|
|
65
|
struct ConsumptionMonitorView: View {
|
|
|
66
|
@EnvironmentObject private var appData: AppData
|
|
|
67
|
|
|
|
68
|
@State private var selectedMeterMACAddress: String?
|
|
|
69
|
@State private var selectedDeviceID: UUID?
|
|
|
70
|
@State private var discardConfirmationVisibility = false
|
|
|
71
|
|
|
|
72
|
let preferredMeterMACAddress: String?
|
|
|
73
|
|
|
|
74
|
init(preferredMeterMACAddress: String? = nil) {
|
|
|
75
|
self.preferredMeterMACAddress = preferredMeterMACAddress
|
|
|
76
|
_selectedMeterMACAddress = State(initialValue: preferredMeterMACAddress)
|
|
|
77
|
}
|
|
|
78
|
|
|
|
79
|
var body: some View {
|
|
|
80
|
ScrollView {
|
|
|
81
|
VStack(spacing: 18) {
|
|
|
82
|
if let session = activeSession {
|
|
|
83
|
activeSessionCard(session)
|
|
|
84
|
liveMetricsCard(session)
|
|
|
85
|
} else {
|
|
|
86
|
setupCard
|
|
|
87
|
}
|
|
|
88
|
savedSessionsList
|
|
|
89
|
}
|
|
|
90
|
.padding()
|
|
|
91
|
}
|
|
|
92
|
.background(
|
|
|
93
|
LinearGradient(
|
|
|
94
|
colors: [.purple.opacity(0.16), Color.clear],
|
|
|
95
|
startPoint: .topLeading,
|
|
|
96
|
endPoint: .bottomTrailing
|
|
|
97
|
)
|
|
|
98
|
.ignoresSafeArea()
|
|
|
99
|
)
|
|
|
100
|
.navigationTitle("Consumption Monitor")
|
|
|
101
|
.navigationBarTitleDisplayMode(.inline)
|
|
|
102
|
.confirmationDialog(
|
|
|
103
|
"Stop and discard this session?",
|
|
|
104
|
isPresented: $discardConfirmationVisibility,
|
|
|
105
|
titleVisibility: .visible
|
|
|
106
|
) {
|
|
|
107
|
Button("Discard", role: .destructive) {
|
|
|
108
|
if let session = activeSession {
|
|
|
109
|
_ = appData.stopConsumptionMonitor(for: session.meterMACAddress, save: false)
|
|
|
110
|
}
|
|
|
111
|
}
|
|
|
112
|
Button("Cancel", role: .cancel) {}
|
|
|
113
|
} message: {
|
|
|
114
|
Text("The current session data will be lost and nothing will be saved.")
|
|
|
115
|
}
|
|
|
116
|
}
|
|
|
117
|
|
|
|
118
|
// MARK: - Computed
|
|
|
119
|
|
|
|
120
|
private var liveMeterSummaries: [AppData.MeterSummary] {
|
|
|
121
|
appData.meterSummaries.filter { $0.meter != nil }
|
|
|
122
|
}
|
|
|
123
|
|
|
|
124
|
private var availableDevices: [ChargedDeviceSummary] {
|
|
|
125
|
appData.deviceSummaries
|
|
|
126
|
}
|
|
|
127
|
|
|
|
128
|
private var activeSession: ConsumptionMonitorLiveSession? {
|
|
|
129
|
let candidates = [selectedMeterMACAddress, preferredMeterMACAddress].compactMap { $0 }
|
|
|
130
|
for mac in candidates {
|
|
|
131
|
if let session = appData.consumptionMonitorSession(for: mac) { return session }
|
|
|
132
|
}
|
|
|
133
|
for summary in liveMeterSummaries {
|
|
|
134
|
if let session = appData.consumptionMonitorSession(for: summary.macAddress) { return session }
|
|
|
135
|
}
|
|
|
136
|
return nil
|
|
|
137
|
}
|
|
|
138
|
|
|
|
139
|
private var selectedDevice: ChargedDeviceSummary? {
|
|
|
140
|
guard let id = selectedDeviceID else { return nil }
|
|
|
141
|
return availableDevices.first { $0.id == id }
|
|
|
142
|
}
|
|
|
143
|
|
|
|
144
|
private var selectedMeterSummary: AppData.MeterSummary? {
|
|
|
145
|
guard let mac = selectedMeterMACAddress else { return nil }
|
|
|
146
|
return liveMeterSummaries.first { $0.macAddress == mac }
|
|
|
147
|
}
|
|
|
148
|
|
|
|
149
|
private var savedSessions: [ConsumptionMonitorSessionSummary] {
|
|
|
150
|
guard let id = selectedDeviceID else { return [] }
|
|
|
151
|
return appData.chargedDeviceSummary(id: id)?.consumptionSessions.filter { !$0.isOpen } ?? []
|
|
|
152
|
}
|
|
|
153
|
|
|
|
154
|
private var canStart: Bool {
|
|
|
155
|
selectedDeviceID != nil && selectedMeterSummary != nil && activeSession == nil
|
|
|
156
|
}
|
|
|
157
|
|
|
|
158
|
// MARK: - Setup Card
|
|
|
159
|
|
|
|
160
|
private var setupCard: some View {
|
|
|
161
|
MeterInfoCardView(title: "New Session", tint: .purple) {
|
|
|
162
|
VStack(alignment: .leading, spacing: 12) {
|
|
|
163
|
if liveMeterSummaries.isEmpty {
|
|
|
164
|
Text("Connect a live meter first to start a consumption monitor session.")
|
|
|
165
|
.font(.footnote)
|
|
|
166
|
.foregroundColor(.secondary)
|
|
|
167
|
} else {
|
|
|
168
|
Text("Device")
|
|
|
169
|
.font(.subheadline.weight(.semibold))
|
|
|
170
|
|
|
|
171
|
if availableDevices.isEmpty {
|
|
|
172
|
Text("No devices available. Add a device in the sidebar first.")
|
|
|
173
|
.font(.caption)
|
|
|
174
|
.foregroundColor(.secondary)
|
|
|
175
|
} else {
|
|
|
176
|
Picker("Device", selection: $selectedDeviceID) {
|
|
|
177
|
Text("Select Device").tag(Optional<UUID>.none)
|
|
|
178
|
ForEach(availableDevices) { device in
|
|
|
179
|
Text(device.name).tag(Optional(device.id))
|
|
|
180
|
}
|
|
|
181
|
}
|
|
|
182
|
.pickerStyle(.menu)
|
|
|
183
|
}
|
|
|
184
|
|
|
|
185
|
Text("Meter")
|
|
|
186
|
.font(.subheadline.weight(.semibold))
|
|
|
187
|
|
|
|
188
|
Picker("Meter", selection: $selectedMeterMACAddress) {
|
|
|
189
|
Text("Select Meter").tag(Optional<String>.none)
|
|
|
190
|
ForEach(liveMeterSummaries) { summary in
|
|
|
191
|
Text(summary.displayName).tag(Optional(summary.macAddress))
|
|
|
192
|
}
|
|
|
193
|
}
|
|
|
194
|
.pickerStyle(.menu)
|
|
|
195
|
|
|
|
196
|
Button("Start Session") {
|
|
|
197
|
startSession()
|
|
|
198
|
}
|
|
|
199
|
.disabled(!canStart)
|
|
|
200
|
.buttonStyle(.borderedProminent)
|
|
|
201
|
.tint(.purple)
|
|
|
202
|
}
|
|
|
203
|
}
|
|
|
204
|
|
|
|
205
|
if activeSession == nil, selectedDevice != nil, selectedMeterSummary == nil {
|
|
|
206
|
Text("Select a meter to begin.")
|
|
|
207
|
.font(.caption)
|
|
|
208
|
.foregroundColor(.secondary)
|
|
|
209
|
} else if activeSession == nil, selectedMeterSummary != nil, selectedDevice == nil {
|
|
|
210
|
Text("Select the device you want to monitor.")
|
|
|
211
|
.font(.caption)
|
|
|
212
|
.foregroundColor(.secondary)
|
|
|
213
|
} else if activeSession == nil, canStart {
|
|
|
214
|
Text("Samples are recorded every 60 seconds at a rate of 60 per hour.")
|
|
|
215
|
.font(.caption)
|
|
|
216
|
.foregroundColor(.secondary)
|
|
|
217
|
}
|
|
|
218
|
}
|
|
|
219
|
}
|
|
|
220
|
|
|
|
221
|
// MARK: - Active Session Card
|
|
|
222
|
|
|
|
223
|
private func activeSessionCard(_ session: ConsumptionMonitorLiveSession) -> some View {
|
|
|
224
|
MeterInfoCardView(
|
|
|
225
|
title: "Session Running",
|
|
|
226
|
infoMessage: "Samples are stored every 60 seconds. The session persists across app restarts until you stop it.",
|
|
|
227
|
tint: .purple
|
|
|
228
|
) {
|
|
|
229
|
if let device = appData.chargedDeviceSummary(id: session.chargedDeviceID) {
|
|
|
230
|
MeterInfoRowView(label: "Device", value: device.name)
|
|
|
231
|
}
|
|
|
232
|
if let summary = liveMeterSummaries.first(where: { $0.macAddress == session.meterMACAddress }) {
|
|
|
233
|
MeterInfoRowView(label: "Meter", value: summary.displayName)
|
|
|
234
|
}
|
|
|
235
|
MeterInfoRowView(label: "Duration", value: formattedDuration(session.elapsedDuration))
|
|
|
236
|
MeterInfoRowView(label: "Samples", value: "\(session.committedSampleCount) × 60 s")
|
|
|
237
|
MeterInfoRowView(label: "Total Energy", value: energyLabel(session.cumulativeEnergyWh))
|
|
|
238
|
|
|
|
239
|
HStack(spacing: 12) {
|
|
|
240
|
Button("Save & Stop") {
|
|
|
241
|
_ = appData.stopConsumptionMonitor(for: session.meterMACAddress, save: true)
|
|
|
242
|
}
|
|
|
243
|
.disabled(session.committedSampleCount == 0)
|
|
|
244
|
|
|
|
245
|
Button("Discard") {
|
|
|
246
|
discardConfirmationVisibility = true
|
|
|
247
|
}
|
|
|
248
|
.foregroundColor(.red)
|
|
|
249
|
}
|
|
|
250
|
.buttonStyle(.borderedProminent)
|
|
|
251
|
.tint(.purple)
|
|
|
252
|
}
|
|
|
253
|
}
|
|
|
254
|
|
|
|
255
|
// MARK: - Live Metrics Card
|
|
|
256
|
|
|
|
257
|
private func liveMetricsCard(_ session: ConsumptionMonitorLiveSession) -> some View {
|
|
|
258
|
VStack(spacing: 18) {
|
|
|
259
|
MeterInfoCardView(title: "Live Reading", tint: .indigo) {
|
|
|
260
|
MeterInfoRowView(label: "Power", value: "\(session.currentPowerWatts.format(decimalDigits: 3)) W")
|
|
|
261
|
MeterInfoRowView(label: "Current", value: "\(session.currentCurrentAmps.format(decimalDigits: 3)) A")
|
|
|
262
|
MeterInfoRowView(label: "Voltage", value: "\(session.currentVoltageVolts.format(decimalDigits: 3)) V")
|
|
|
263
|
}
|
|
|
264
|
|
|
|
265
|
if session.committedSamples.count >= 2 {
|
|
|
266
|
liveChartCard(session.committedSamples)
|
|
|
267
|
}
|
|
|
268
|
|
|
|
269
|
if session.cumulativeEnergyWh > 0 {
|
|
|
270
|
projectionCard(
|
|
|
271
|
averagePowerWatts: session.cumulativeEnergyWh / max(session.elapsedDuration / 3600, 0.001),
|
|
|
272
|
totalEnergyWh: session.cumulativeEnergyWh
|
|
|
273
|
)
|
|
|
274
|
}
|
|
|
275
|
}
|
|
|
276
|
}
|
|
|
277
|
|
|
|
278
|
@ViewBuilder
|
|
|
279
|
private func liveChartCard(_ samples: [ConsumptionMonitorSample]) -> some View {
|
|
|
280
|
if #available(iOS 16, *) {
|
|
|
281
|
MeterInfoCardView(title: "Power Over Time", tint: .purple) {
|
|
|
282
|
consumptionChart(samples: samples, tint: .purple)
|
|
|
283
|
}
|
|
|
284
|
}
|
|
|
285
|
}
|
|
|
286
|
|
|
|
287
|
// MARK: - Projections Card
|
|
|
288
|
|
|
|
289
|
private func projectionCard(averagePowerWatts: Double, totalEnergyWh: Double) -> some View {
|
|
|
290
|
MeterInfoCardView(title: "Consumption Projection", tint: .teal) {
|
|
|
291
|
MeterInfoRowView(label: "Average Power", value: "\(averagePowerWatts.format(decimalDigits: 3)) W")
|
|
|
292
|
MeterInfoRowView(label: "24 Hours", value: energyLabel(averagePowerWatts * 24))
|
|
|
293
|
MeterInfoRowView(label: "7 Days", value: energyLabel(averagePowerWatts * 24 * 7))
|
|
|
294
|
MeterInfoRowView(label: "30 Days", value: energyLabel(averagePowerWatts * 24 * 30))
|
|
|
295
|
MeterInfoRowView(label: "1 Year", value: energyLabel(averagePowerWatts * 24 * 365))
|
|
|
296
|
}
|
|
|
297
|
}
|
|
|
298
|
|
|
|
299
|
// MARK: - Saved Sessions List
|
|
|
300
|
|
|
|
301
|
@ViewBuilder
|
|
|
302
|
private var savedSessionsList: some View {
|
|
|
303
|
if !savedSessions.isEmpty {
|
|
|
304
|
MeterInfoCardView(title: "Saved Sessions", tint: .purple) {
|
|
|
305
|
ForEach(savedSessions) { session in
|
|
|
306
|
NavigationLink(destination: ConsumptionSessionDetailView(session: session)) {
|
|
|
307
|
HStack {
|
|
|
308
|
VStack(alignment: .leading, spacing: 2) {
|
|
|
309
|
Text(session.startedAt, style: .date)
|
|
|
310
|
.font(.subheadline.weight(.semibold))
|
|
|
311
|
Text("\(formattedDuration(session.duration)) · \(energyLabel(session.totalEnergyWh)) total")
|
|
|
312
|
.font(.caption)
|
|
|
313
|
.foregroundColor(.secondary)
|
|
|
314
|
}
|
|
|
315
|
Spacer()
|
|
|
316
|
Image(systemName: "chevron.right")
|
|
|
317
|
.font(.caption.weight(.semibold))
|
|
|
318
|
.foregroundColor(.secondary)
|
|
|
319
|
}
|
|
|
320
|
.padding(.vertical, 4)
|
|
|
321
|
}
|
|
|
322
|
.buttonStyle(.plain)
|
|
|
323
|
}
|
|
|
324
|
}
|
|
|
325
|
}
|
|
|
326
|
}
|
|
|
327
|
|
|
|
328
|
// MARK: - Actions
|
|
|
329
|
|
|
|
330
|
private func startSession() {
|
|
|
331
|
guard let deviceID = selectedDeviceID,
|
|
|
332
|
let meterSummary = selectedMeterSummary,
|
|
|
333
|
let meter = meterSummary.meter else { return }
|
|
|
334
|
_ = appData.startConsumptionMonitor(for: deviceID, on: meter)
|
|
|
335
|
}
|
|
|
336
|
}
|
|
|
337
|
|
|
|
338
|
// MARK: - Session Detail
|
|
|
339
|
|
|
|
340
|
struct ConsumptionSessionDetailView: View {
|
|
|
341
|
@EnvironmentObject private var appData: AppData
|
|
|
342
|
|
|
|
343
|
let session: ConsumptionMonitorSessionSummary
|
|
|
344
|
|
|
|
345
|
@State private var deleteConfirmationVisibility = false
|
|
|
346
|
|
|
|
347
|
var body: some View {
|
|
|
348
|
ScrollView {
|
|
|
349
|
VStack(spacing: 18) {
|
|
|
350
|
overviewCard
|
|
|
351
|
if session.averagePowerWatts > 0 {
|
|
|
352
|
projectionCard
|
|
|
353
|
}
|
|
|
354
|
if session.samples.count >= 2 {
|
|
|
355
|
chartCard
|
|
|
356
|
}
|
|
|
357
|
statsCard
|
|
|
358
|
}
|
|
|
359
|
.padding()
|
|
|
360
|
}
|
|
|
361
|
.background(
|
|
|
362
|
LinearGradient(
|
|
|
363
|
colors: [.purple.opacity(0.14), Color.clear],
|
|
|
364
|
startPoint: .topLeading,
|
|
|
365
|
endPoint: .bottomTrailing
|
|
|
366
|
)
|
|
|
367
|
.ignoresSafeArea()
|
|
|
368
|
)
|
|
|
369
|
.navigationTitle("Consumption Session")
|
|
|
370
|
.navigationBarTitleDisplayMode(.inline)
|
|
|
371
|
.toolbar {
|
|
|
372
|
ToolbarItem(placement: .destructiveAction) {
|
|
|
373
|
Button(role: .destructive) {
|
|
|
374
|
deleteConfirmationVisibility = true
|
|
|
375
|
} label: {
|
|
|
376
|
Image(systemName: "trash")
|
|
|
377
|
}
|
|
|
378
|
}
|
|
|
379
|
}
|
|
|
380
|
.confirmationDialog("Delete this session?", isPresented: $deleteConfirmationVisibility, titleVisibility: .visible) {
|
|
|
381
|
Button("Delete", role: .destructive) {
|
|
|
382
|
_ = appData.deleteConsumptionSession(id: session.id, deviceID: session.chargedDeviceID)
|
|
|
383
|
}
|
|
|
384
|
Button("Cancel", role: .cancel) {}
|
|
|
385
|
}
|
|
|
386
|
}
|
|
|
387
|
|
|
|
388
|
// MARK: - Cards
|
|
|
389
|
|
|
|
390
|
private var overviewCard: some View {
|
|
|
391
|
MeterInfoCardView(title: "Overview", tint: .purple) {
|
|
|
392
|
MeterInfoRowView(label: "Started", value: session.startedAt.formatted(date: .abbreviated, time: .shortened))
|
|
|
393
|
if let endedAt = session.endedAt {
|
|
|
394
|
MeterInfoRowView(label: "Ended", value: endedAt.formatted(date: .abbreviated, time: .shortened))
|
|
|
395
|
}
|
|
|
396
|
MeterInfoRowView(label: "Duration", value: formattedDuration(session.duration))
|
|
|
397
|
MeterInfoRowView(label: "Samples", value: "\(session.sampleCount) × 60 s")
|
|
|
398
|
MeterInfoRowView(label: "Total Energy", value: energyLabel(session.totalEnergyWh))
|
|
|
399
|
if let meterName = session.meterName {
|
|
|
400
|
MeterInfoRowView(label: "Meter", value: meterName)
|
|
|
401
|
}
|
|
|
402
|
}
|
|
|
403
|
}
|
|
|
404
|
|
|
|
405
|
private var projectionCard: some View {
|
|
|
406
|
MeterInfoCardView(title: "Consumption Projection", tint: .teal) {
|
|
|
407
|
MeterInfoRowView(label: "Average Power", value: "\(session.averagePowerWatts.format(decimalDigits: 3)) W")
|
|
|
408
|
MeterInfoRowView(label: "24 Hours", value: energyLabel(session.projectedDailyEnergyWh))
|
|
|
409
|
MeterInfoRowView(label: "7 Days", value: energyLabel(session.projectedWeeklyEnergyWh))
|
|
|
410
|
MeterInfoRowView(label: "30 Days", value: energyLabel(session.projectedMonthlyEnergyWh))
|
|
|
411
|
MeterInfoRowView(label: "1 Year", value: energyLabel(session.projectedYearlyEnergyWh))
|
|
|
412
|
}
|
|
|
413
|
}
|
|
|
414
|
|
|
|
415
|
private var statsCard: some View {
|
|
|
416
|
MeterInfoCardView(title: "Statistics", tint: .indigo) {
|
|
|
417
|
MeterInfoRowView(label: "Min Power", value: "\(session.minimumPowerWatts.format(decimalDigits: 3)) W")
|
|
|
418
|
MeterInfoRowView(label: "Max Power", value: "\(session.maximumPowerWatts.format(decimalDigits: 3)) W")
|
|
|
419
|
MeterInfoRowView(label: "Avg Current", value: "\(session.averageCurrentAmps.format(decimalDigits: 3)) A")
|
|
|
420
|
MeterInfoRowView(label: "Avg Voltage", value: "\(session.averageVoltageVolts.format(decimalDigits: 3)) V")
|
|
|
421
|
}
|
|
|
422
|
}
|
|
|
423
|
|
|
|
424
|
@ViewBuilder
|
|
|
425
|
private var chartCard: some View {
|
|
|
426
|
if #available(iOS 16, *) {
|
|
|
427
|
MeterInfoCardView(title: "Power Over Time", tint: .purple) {
|
|
|
428
|
consumptionChart(samples: session.samples, tint: .purple)
|
|
|
429
|
}
|
|
|
430
|
}
|
|
|
431
|
}
|
|
|
432
|
}
|