@@ -40,6 +40,7 @@ final class AppData : ObservableObject {
|
||
| 40 | 40 |
} |
| 41 | 41 |
|
| 42 | 42 |
@ICloudDefault(key: "MeterNames", defaultValue: [:]) var meterNames: [String:String] |
| 43 |
+ @ICloudDefault(key: "TC66TemperatureUnits", defaultValue: [:]) var tc66TemperatureUnits: [String:String] |
|
| 43 | 44 |
func test(notification: NotificationCenter.Publisher.Output) -> Void {
|
| 44 | 45 |
if let changedKeys = notification.userInfo?["NSUbiquitousKeyValueStoreChangedKeysKey"] as? [String] {
|
| 45 | 46 |
var somethingChanged = false |
@@ -54,6 +55,11 @@ final class AppData : ObservableObject {
|
||
| 54 | 55 |
} |
| 55 | 56 |
} |
| 56 | 57 |
} |
| 58 |
+ case "TC66TemperatureUnits": |
|
| 59 |
+ for meter in self.meters.values where meter.supportsManualTemperatureUnitSelection {
|
|
| 60 |
+ meter.reloadTemperatureUnitPreference() |
|
| 61 |
+ somethingChanged = true |
|
| 62 |
+ } |
|
| 57 | 63 |
default: |
| 58 | 64 |
track("Unknown key: '\(changedKey)' changed in iCloud)")
|
| 59 | 65 |
} |
@@ -23,7 +23,7 @@ class ChartContext {
|
||
| 23 | 23 |
get {
|
| 24 | 24 |
guard rect != nil else {
|
| 25 | 25 |
track("Invalid Context")
|
| 26 |
- fatalError() |
|
| 26 |
+ return .zero |
|
| 27 | 27 |
} |
| 28 | 28 |
return rect!.size |
| 29 | 29 |
} |
@@ -33,7 +33,7 @@ class ChartContext {
|
||
| 33 | 33 |
get {
|
| 34 | 34 |
guard rect != nil else {
|
| 35 | 35 |
track("Invalid Context")
|
| 36 |
- fatalError() |
|
| 36 |
+ return .zero |
|
| 37 | 37 |
} |
| 38 | 38 |
return rect!.origin |
| 39 | 39 |
} |
@@ -51,7 +51,7 @@ class ChartContext {
|
||
| 51 | 51 |
|
| 52 | 52 |
func reset() {
|
| 53 | 53 |
rect = nil |
| 54 |
- padding() |
|
| 54 |
+ pad = 0 |
|
| 55 | 55 |
} |
| 56 | 56 |
func include( point: CGPoint ) {
|
| 57 | 57 |
if rect == nil {
|
@@ -67,10 +67,27 @@ class ChartContext {
|
||
| 67 | 67 |
guard rect != nil else {
|
| 68 | 68 |
track("Invalid Context")
|
| 69 | 69 |
pad = 0 |
| 70 |
- fatalError() |
|
| 70 |
+ return |
|
| 71 | 71 |
} |
| 72 | 72 |
pad = rect!.size.height * Constants.chartUnderscan |
| 73 | 73 |
} |
| 74 |
+ |
|
| 75 |
+ func ensureMinimumSize(width minimumWidth: CGFloat = 0, height minimumHeight: CGFloat = 0) {
|
|
| 76 |
+ guard var rect else { return }
|
|
| 77 |
+ |
|
| 78 |
+ if rect.width < minimumWidth {
|
|
| 79 |
+ let delta = (minimumWidth - rect.width) / 2 |
|
| 80 |
+ rect = rect.insetBy(dx: -delta, dy: 0) |
|
| 81 |
+ } |
|
| 82 |
+ |
|
| 83 |
+ if rect.height < minimumHeight {
|
|
| 84 |
+ let delta = (minimumHeight - rect.height) / 2 |
|
| 85 |
+ rect = rect.insetBy(dx: 0, dy: -delta) |
|
| 86 |
+ } |
|
| 87 |
+ |
|
| 88 |
+ self.rect = rect |
|
| 89 |
+ padding() |
|
| 90 |
+ } |
|
| 74 | 91 |
|
| 75 | 92 |
func yAxisLabel( for itemNo: Int, of items: Int ) -> Double {
|
| 76 | 93 |
let labelSpace = Double(rect!.height) / Double(items - 1) |
@@ -86,19 +103,15 @@ class ChartContext {
|
||
| 86 | 103 |
} |
| 87 | 104 |
|
| 88 | 105 |
func placeInRect (point: CGPoint) -> CGPoint {
|
| 89 |
- guard rect != nil else {
|
|
| 106 |
+ guard let rect else {
|
|
| 90 | 107 |
track("Invalid Context")
|
| 91 |
- fatalError() |
|
| 92 |
- } |
|
| 93 |
- guard rect!.width != 0 else {
|
|
| 94 |
- fatalError() |
|
| 95 |
- } |
|
| 96 |
- guard rect!.height != 0 else {
|
|
| 97 |
- fatalError() |
|
| 108 |
+ return .zero |
|
| 98 | 109 |
} |
| 99 | 110 |
|
| 100 |
- let x = (point.x - rect!.origin.x)/rect!.width |
|
| 101 |
- let y = (pad + point.y - rect!.origin.y)/rect!.height |
|
| 111 |
+ let width = max(rect.width, 1) |
|
| 112 |
+ let height = max(rect.height, 0.1) |
|
| 113 |
+ let x = (point.x - rect.origin.x)/width |
|
| 114 |
+ let y = (pad + point.y - rect.origin.y)/height |
|
| 102 | 115 |
return CGPoint(x: x, y: 1 - y * Constants.chartOverscan) |
| 103 | 116 |
} |
| 104 | 117 |
} |
@@ -45,6 +45,20 @@ class Measurements : ObservableObject {
|
||
| 45 | 45 |
context.reset() |
| 46 | 46 |
self.objectWillChange.send() |
| 47 | 47 |
} |
| 48 |
+ |
|
| 49 |
+ func trim(before cutoff: Date) {
|
|
| 50 |
+ points = points |
|
| 51 |
+ .filter { $0.timestamp >= cutoff }
|
|
| 52 |
+ .enumerated() |
|
| 53 |
+ .map { index, point in
|
|
| 54 |
+ Measurement.Point(id: index, timestamp: point.timestamp, value: point.value) |
|
| 55 |
+ } |
|
| 56 |
+ context.reset() |
|
| 57 |
+ for point in points {
|
|
| 58 |
+ context.include(point: point.point()) |
|
| 59 |
+ } |
|
| 60 |
+ self.objectWillChange.send() |
|
| 61 |
+ } |
|
| 48 | 62 |
} |
| 49 | 63 |
|
| 50 | 64 |
@Published var power = Measurement() |
@@ -57,6 +71,18 @@ class Measurements : ObservableObject {
|
||
| 57 | 71 |
private var powerSum: Double = 0 |
| 58 | 72 |
private var voltageSum: Double = 0 |
| 59 | 73 |
private var currentSum: Double = 0 |
| 74 |
+ |
|
| 75 |
+ func reset() {
|
|
| 76 |
+ power.reset() |
|
| 77 |
+ voltage.reset() |
|
| 78 |
+ current.reset() |
|
| 79 |
+ lastPointTimestamp = 0 |
|
| 80 |
+ itemsInSum = 0 |
|
| 81 |
+ powerSum = 0 |
|
| 82 |
+ voltageSum = 0 |
|
| 83 |
+ currentSum = 0 |
|
| 84 |
+ self.objectWillChange.send() |
|
| 85 |
+ } |
|
| 60 | 86 |
|
| 61 | 87 |
func remove(at idx: Int) {
|
| 62 | 88 |
power.removeValue(index: idx) |
@@ -65,6 +91,13 @@ class Measurements : ObservableObject {
|
||
| 65 | 91 |
self.objectWillChange.send() |
| 66 | 92 |
} |
| 67 | 93 |
|
| 94 |
+ func trim(before cutoff: Date) {
|
|
| 95 |
+ power.trim(before: cutoff) |
|
| 96 |
+ voltage.trim(before: cutoff) |
|
| 97 |
+ current.trim(before: cutoff) |
|
| 98 |
+ self.objectWillChange.send() |
|
| 99 |
+ } |
|
| 100 |
+ |
|
| 68 | 101 |
|
| 69 | 102 |
|
| 70 | 103 |
func addValues(timestamp: Date, power: Double, voltage: Double, current: Double) {
|
@@ -74,7 +107,7 @@ class Measurements : ObservableObject {
|
||
| 74 | 107 |
} |
| 75 | 108 |
if lastPointTimestamp == valuesTimestamp {
|
| 76 | 109 |
itemsInSum += 1 |
| 77 |
- powerSum += voltage |
|
| 110 |
+ powerSum += power |
|
| 78 | 111 |
voltageSum += voltage |
| 79 | 112 |
currentSum += current |
| 80 | 113 |
} |
@@ -28,6 +28,37 @@ enum Model: CaseIterable {
|
||
| 28 | 28 |
case TC66C |
| 29 | 29 |
} |
| 30 | 30 |
|
| 31 |
+enum TemperatureUnitPreference: String, CaseIterable, Identifiable {
|
|
| 32 |
+ case celsius |
|
| 33 |
+ case fahrenheit |
|
| 34 |
+ |
|
| 35 |
+ var id: String { rawValue }
|
|
| 36 |
+ |
|
| 37 |
+ var title: String {
|
|
| 38 |
+ switch self {
|
|
| 39 |
+ case .celsius: |
|
| 40 |
+ return "Celsius" |
|
| 41 |
+ case .fahrenheit: |
|
| 42 |
+ return "Fahrenheit" |
|
| 43 |
+ } |
|
| 44 |
+ } |
|
| 45 |
+ |
|
| 46 |
+ var symbol: String {
|
|
| 47 |
+ switch self {
|
|
| 48 |
+ case .celsius: |
|
| 49 |
+ return "℃" |
|
| 50 |
+ case .fahrenheit: |
|
| 51 |
+ return "℉" |
|
| 52 |
+ } |
|
| 53 |
+ } |
|
| 54 |
+} |
|
| 55 |
+ |
|
| 56 |
+enum ChargeRecordState {
|
|
| 57 |
+ case waitingForStart |
|
| 58 |
+ case active |
|
| 59 |
+ case completed |
|
| 60 |
+} |
|
| 61 |
+ |
|
| 31 | 62 |
class Meter : NSObject, ObservableObject, Identifiable {
|
| 32 | 63 |
|
| 33 | 64 |
enum OperationalState: Int, Comparable {
|
@@ -145,19 +176,68 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 145 | 176 |
capabilities.supportsRecordingThreshold |
| 146 | 177 |
} |
| 147 | 178 |
|
| 179 |
+ var reportsCurrentScreenIndex: Bool {
|
|
| 180 |
+ capabilities.reportsCurrentScreenIndex |
|
| 181 |
+ } |
|
| 182 |
+ |
|
| 183 |
+ var showsDataGroupEnergy: Bool {
|
|
| 184 |
+ capabilities.showsDataGroupEnergy |
|
| 185 |
+ } |
|
| 186 |
+ |
|
| 187 |
+ var highlightsActiveDataGroup: Bool {
|
|
| 188 |
+ if model == .TC66C {
|
|
| 189 |
+ return hasObservedActiveDataGroup |
|
| 190 |
+ } |
|
| 191 |
+ return capabilities.highlightsActiveDataGroup |
|
| 192 |
+ } |
|
| 193 |
+ |
|
| 148 | 194 |
var supportsFahrenheit: Bool {
|
| 149 | 195 |
capabilities.supportsFahrenheit |
| 150 | 196 |
} |
| 151 | 197 |
|
| 198 |
+ var supportsManualTemperatureUnitSelection: Bool {
|
|
| 199 |
+ model == .TC66C |
|
| 200 |
+ } |
|
| 201 |
+ |
|
| 152 | 202 |
var supportsChargerDetection: Bool {
|
| 153 | 203 |
capabilities.supportsChargerDetection |
| 154 | 204 |
} |
| 155 | 205 |
|
| 206 |
+ var dataGroupsTitle: String {
|
|
| 207 |
+ capabilities.dataGroupsTitle |
|
| 208 |
+ } |
|
| 209 |
+ |
|
| 156 | 210 |
var chargerTypeDescription: String {
|
| 157 | 211 |
capabilities.chargerTypeDescription(for: chargerTypeIndex) |
| 158 | 212 |
} |
| 159 | 213 |
|
| 214 |
+ var temperatureUnitDescription: String {
|
|
| 215 |
+ if supportsManualTemperatureUnitSelection {
|
|
| 216 |
+ return tc66TemperatureUnitPreference.title |
|
| 217 |
+ } |
|
| 218 |
+ return supportsFahrenheit ? "Celsius / Fahrenheit" : "Celsius" |
|
| 219 |
+ } |
|
| 220 |
+ |
|
| 221 |
+ var primaryTemperatureDescription: String {
|
|
| 222 |
+ let value = temperatureCelsius.format(decimalDigits: 0) |
|
| 223 |
+ if supportsManualTemperatureUnitSelection {
|
|
| 224 |
+ return "\(value)\(tc66TemperatureUnitPreference.symbol)" |
|
| 225 |
+ } |
|
| 226 |
+ if let symbol = capabilities.primaryTemperatureUnitSymbol {
|
|
| 227 |
+ return "\(value)\(symbol)" |
|
| 228 |
+ } |
|
| 229 |
+ return value |
|
| 230 |
+ } |
|
| 231 |
+ |
|
| 232 |
+ var secondaryTemperatureDescription: String? {
|
|
| 233 |
+ guard supportsFahrenheit else { return nil }
|
|
| 234 |
+ return "\(temperatureFahrenheit.format(decimalDigits: 0))℉" |
|
| 235 |
+ } |
|
| 236 |
+ |
|
| 160 | 237 |
var currentScreenDescription: String {
|
| 238 |
+ guard reportsCurrentScreenIndex else {
|
|
| 239 |
+ return "Page Controls" |
|
| 240 |
+ } |
|
| 161 | 241 |
if let label = capabilities.screenDescription(for: currentScreen) {
|
| 162 | 242 |
return "Screen \(currentScreen): \(label)" |
| 163 | 243 |
} |
@@ -184,8 +264,59 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 184 | 264 |
return String(format: "%02d:%02d", minutes, seconds) |
| 185 | 265 |
} |
| 186 | 266 |
|
| 267 |
+ var chargeRecordDurationDescription: String {
|
|
| 268 |
+ let totalSeconds = Int(chargeRecordDuration) |
|
| 269 |
+ let hours = totalSeconds / 3600 |
|
| 270 |
+ let minutes = (totalSeconds % 3600) / 60 |
|
| 271 |
+ let seconds = totalSeconds % 60 |
|
| 272 |
+ |
|
| 273 |
+ if hours > 0 {
|
|
| 274 |
+ return String(format: "%d:%02d:%02d", hours, minutes, seconds) |
|
| 275 |
+ } |
|
| 276 |
+ return String(format: "%02d:%02d", minutes, seconds) |
|
| 277 |
+ } |
|
| 278 |
+ |
|
| 279 |
+ var chargeRecordTimeRange: ClosedRange<Date>? {
|
|
| 280 |
+ guard let start = chargeRecordStartTimestamp else { return nil }
|
|
| 281 |
+ let end = chargeRecordEndTimestamp ?? chargeRecordLastTimestamp |
|
| 282 |
+ guard let end else { return nil }
|
|
| 283 |
+ return start...end |
|
| 284 |
+ } |
|
| 285 |
+ |
|
| 286 |
+ var chargeRecordStatusText: String {
|
|
| 287 |
+ switch chargeRecordState {
|
|
| 288 |
+ case .waitingForStart: |
|
| 289 |
+ return "Waiting" |
|
| 290 |
+ case .active: |
|
| 291 |
+ return "Active" |
|
| 292 |
+ case .completed: |
|
| 293 |
+ return "Completed" |
|
| 294 |
+ } |
|
| 295 |
+ } |
|
| 296 |
+ |
|
| 297 |
+ var chargeRecordStatusColor: Color {
|
|
| 298 |
+ switch chargeRecordState {
|
|
| 299 |
+ case .waitingForStart: |
|
| 300 |
+ return .secondary |
|
| 301 |
+ case .active: |
|
| 302 |
+ return .red |
|
| 303 |
+ case .completed: |
|
| 304 |
+ return .green |
|
| 305 |
+ } |
|
| 306 |
+ } |
|
| 307 |
+ |
|
| 187 | 308 |
var dataGroupsHint: String? {
|
| 188 |
- capabilities.dataGroupsHint |
|
| 309 |
+ if model == .TC66C {
|
|
| 310 |
+ if hasObservedActiveDataGroup {
|
|
| 311 |
+ return "The active memory is inferred from the totals that are currently increasing." |
|
| 312 |
+ } |
|
| 313 |
+ return "The device exposes two read-only memories. The active memory is inferred once one total starts increasing." |
|
| 314 |
+ } |
|
| 315 |
+ return capabilities.dataGroupsHint |
|
| 316 |
+ } |
|
| 317 |
+ |
|
| 318 |
+ func dataGroupLabel(for id: UInt8) -> String {
|
|
| 319 |
+ supportsDataGroupCommands ? "\(id)" : "M\(Int(id) + 1)" |
|
| 189 | 320 |
} |
| 190 | 321 |
|
| 191 | 322 |
var recordingThresholdHint: String? {
|
@@ -209,6 +340,19 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 209 | 340 |
} |
| 210 | 341 |
@Published var selectedDataGroup: UInt8 = 0 |
| 211 | 342 |
@Published var dataGroupRecords: [Int : DataGroupRecord] = [:] |
| 343 |
+ @Published var chargeRecordAH: Double = 0 |
|
| 344 |
+ @Published var chargeRecordWH: Double = 0 |
|
| 345 |
+ @Published var chargeRecordDuration: TimeInterval = 0 |
|
| 346 |
+ @Published var chargeRecordStopThreshold: Double = 0.05 |
|
| 347 |
+ @Published var tc66TemperatureUnitPreference: TemperatureUnitPreference = .celsius {
|
|
| 348 |
+ didSet {
|
|
| 349 |
+ guard supportsManualTemperatureUnitSelection else { return }
|
|
| 350 |
+ guard oldValue != tc66TemperatureUnitPreference else { return }
|
|
| 351 |
+ var settings = appData.tc66TemperatureUnits |
|
| 352 |
+ settings[btSerial.macAddress.description] = tc66TemperatureUnitPreference.rawValue |
|
| 353 |
+ appData.tc66TemperatureUnits = settings |
|
| 354 |
+ } |
|
| 355 |
+ } |
|
| 212 | 356 |
|
| 213 | 357 |
@Published var screenBrightness: Int = -1 {
|
| 214 | 358 |
didSet {
|
@@ -268,6 +412,14 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 268 | 412 |
private var recordingThresholdTimestamp = Date() |
| 269 | 413 |
private var recordingThresholdLoadedFromDevice = false |
| 270 | 414 |
private var isApplyingRecordingThresholdFromDevice = false |
| 415 |
+ @Published private(set) var chargeRecordState = ChargeRecordState.waitingForStart |
|
| 416 |
+ private var chargeRecordStartTimestamp: Date? |
|
| 417 |
+ private var chargeRecordEndTimestamp: Date? |
|
| 418 |
+ private var chargeRecordLastTimestamp: Date? |
|
| 419 |
+ private var chargeRecordLastCurrent: Double = 0 |
|
| 420 |
+ private var chargeRecordLastPower: Double = 0 |
|
| 421 |
+ private var hasObservedActiveDataGroup = false |
|
| 422 |
+ private var hasSeenTC66Snapshot = false |
|
| 271 | 423 |
|
| 272 | 424 |
init ( model: Model, with serialPort: BluetoothSerial ) {
|
| 273 | 425 |
uuid = serialPort.peripheral.identifier |
@@ -278,11 +430,21 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 278 | 430 |
name = appData.meterNames[serialPort.macAddress.description] ?? serialPort.macAddress.description |
| 279 | 431 |
super.init() |
| 280 | 432 |
btSerial.delegate = self |
| 433 |
+ reloadTemperatureUnitPreference() |
|
| 281 | 434 |
//name = dataStore.meterNames[macAddress.description] ?? peripheral.name! |
| 282 | 435 |
for index in stride(from: 0, through: 9, by: 1) {
|
| 283 | 436 |
dataGroupRecords[index] = DataGroupRecord(ah:0, wh: 0) |
| 284 | 437 |
} |
| 285 | 438 |
} |
| 439 |
+ |
|
| 440 |
+ func reloadTemperatureUnitPreference() {
|
|
| 441 |
+ guard supportsManualTemperatureUnitSelection else { return }
|
|
| 442 |
+ let rawValue = appData.tc66TemperatureUnits[btSerial.macAddress.description] ?? TemperatureUnitPreference.celsius.rawValue |
|
| 443 |
+ let persistedPreference = TemperatureUnitPreference(rawValue: rawValue) ?? .celsius |
|
| 444 |
+ if tc66TemperatureUnitPreference != persistedPreference {
|
|
| 445 |
+ tc66TemperatureUnitPreference = persistedPreference |
|
| 446 |
+ } |
|
| 447 |
+ } |
|
| 286 | 448 |
|
| 287 | 449 |
func dataDumpRequest() {
|
| 288 | 450 |
if commandQueue.isEmpty {
|
@@ -333,6 +495,7 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 333 | 495 |
track("\(name) - Error: \(error)")
|
| 334 | 496 |
} |
| 335 | 497 |
} |
| 498 |
+ updateChargeRecord(at: dataDumpRequestTimestamp) |
|
| 336 | 499 |
measurements.addValues(timestamp: dataDumpRequestTimestamp, power: power, voltage: voltage, current: current) |
| 337 | 500 |
// DispatchQueue.global(qos: .userInitiated).asyncAfter( deadline: .now() + 0.33 ) {
|
| 338 | 501 |
// //track("\(name) - Scheduled new request.")
|
@@ -392,6 +555,11 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 392 | 555 |
} |
| 393 | 556 |
|
| 394 | 557 |
private func apply(tc66Snapshot snapshot: TC66Snapshot) {
|
| 558 |
+ if hasSeenTC66Snapshot {
|
|
| 559 |
+ inferTC66ActiveDataGroup(from: snapshot) |
|
| 560 |
+ } else {
|
|
| 561 |
+ hasSeenTC66Snapshot = true |
|
| 562 |
+ } |
|
| 395 | 563 |
reportedModelName = snapshot.modelName |
| 396 | 564 |
firmwareVersion = snapshot.firmwareVersion |
| 397 | 565 |
serialNumber = snapshot.serialNumber |
@@ -407,6 +575,72 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 407 | 575 |
usbPlusVoltage = snapshot.usbPlusVoltage |
| 408 | 576 |
usbMinusVoltage = snapshot.usbMinusVoltage |
| 409 | 577 |
} |
| 578 |
+ |
|
| 579 |
+ private func inferTC66ActiveDataGroup(from snapshot: TC66Snapshot) {
|
|
| 580 |
+ let candidate = snapshot.dataGroupRecords.compactMap { entry -> (UInt8, Double)? in
|
|
| 581 |
+ let index = entry.key |
|
| 582 |
+ let record = entry.value |
|
| 583 |
+ guard let previous = dataGroupRecords[index] else { return nil }
|
|
| 584 |
+ let deltaAH = max(record.ah - previous.ah, 0) |
|
| 585 |
+ let deltaWH = max(record.wh - previous.wh, 0) |
|
| 586 |
+ let score = deltaAH + deltaWH |
|
| 587 |
+ guard score > 0 else { return nil }
|
|
| 588 |
+ return (UInt8(index), score) |
|
| 589 |
+ } |
|
| 590 |
+ .max { lhs, rhs in lhs.1 < rhs.1 }
|
|
| 591 |
+ |
|
| 592 |
+ if let candidate {
|
|
| 593 |
+ selectedDataGroup = candidate.0 |
|
| 594 |
+ hasObservedActiveDataGroup = true |
|
| 595 |
+ } |
|
| 596 |
+ } |
|
| 597 |
+ |
|
| 598 |
+ private func updateChargeRecord(at timestamp: Date) {
|
|
| 599 |
+ switch chargeRecordState {
|
|
| 600 |
+ case .waitingForStart: |
|
| 601 |
+ guard current > chargeRecordStopThreshold else { return }
|
|
| 602 |
+ chargeRecordState = .active |
|
| 603 |
+ chargeRecordStartTimestamp = timestamp |
|
| 604 |
+ chargeRecordEndTimestamp = timestamp |
|
| 605 |
+ chargeRecordLastTimestamp = timestamp |
|
| 606 |
+ chargeRecordLastCurrent = current |
|
| 607 |
+ chargeRecordLastPower = power |
|
| 608 |
+ case .active: |
|
| 609 |
+ if let lastTimestamp = chargeRecordLastTimestamp {
|
|
| 610 |
+ let deltaSeconds = max(timestamp.timeIntervalSince(lastTimestamp), 0) |
|
| 611 |
+ chargeRecordAH += chargeRecordLastCurrent * deltaSeconds / 3600 |
|
| 612 |
+ chargeRecordWH += chargeRecordLastPower * deltaSeconds / 3600 |
|
| 613 |
+ chargeRecordDuration += deltaSeconds |
|
| 614 |
+ } |
|
| 615 |
+ chargeRecordEndTimestamp = timestamp |
|
| 616 |
+ chargeRecordLastTimestamp = timestamp |
|
| 617 |
+ chargeRecordLastCurrent = current |
|
| 618 |
+ chargeRecordLastPower = power |
|
| 619 |
+ if current <= chargeRecordStopThreshold {
|
|
| 620 |
+ chargeRecordState = .completed |
|
| 621 |
+ } |
|
| 622 |
+ case .completed: |
|
| 623 |
+ break |
|
| 624 |
+ } |
|
| 625 |
+ } |
|
| 626 |
+ |
|
| 627 |
+ func resetChargeRecord() {
|
|
| 628 |
+ chargeRecordAH = 0 |
|
| 629 |
+ chargeRecordWH = 0 |
|
| 630 |
+ chargeRecordDuration = 0 |
|
| 631 |
+ chargeRecordState = .waitingForStart |
|
| 632 |
+ chargeRecordStartTimestamp = nil |
|
| 633 |
+ chargeRecordEndTimestamp = nil |
|
| 634 |
+ chargeRecordLastTimestamp = nil |
|
| 635 |
+ chargeRecordLastCurrent = 0 |
|
| 636 |
+ chargeRecordLastPower = 0 |
|
| 637 |
+ } |
|
| 638 |
+ |
|
| 639 |
+ func resetChargeRecordGraph() {
|
|
| 640 |
+ let cutoff = Date() |
|
| 641 |
+ resetChargeRecord() |
|
| 642 |
+ measurements.trim(before: cutoff) |
|
| 643 |
+ } |
|
| 410 | 644 |
|
| 411 | 645 |
func nextScreen() {
|
| 412 | 646 |
switch model {
|
@@ -13,8 +13,13 @@ struct MeterCapabilities {
|
||
| 13 | 13 |
let supportsRecordingView: Bool |
| 14 | 14 |
let supportsScreenSettings: Bool |
| 15 | 15 |
let supportsRecordingThreshold: Bool |
| 16 |
+ let reportsCurrentScreenIndex: Bool |
|
| 17 |
+ let showsDataGroupEnergy: Bool |
|
| 18 |
+ let highlightsActiveDataGroup: Bool |
|
| 16 | 19 |
let supportsFahrenheit: Bool |
| 17 | 20 |
let supportsChargerDetection: Bool |
| 21 |
+ let primaryTemperatureUnitSymbol: String? |
|
| 22 |
+ let dataGroupsTitle: String |
|
| 18 | 23 |
let chargerTypeDescriptions: [UInt16: String] |
| 19 | 24 |
let screenDescriptions: [UInt16: String] |
| 20 | 25 |
let dataGroupsHint: String? |
@@ -40,8 +45,13 @@ extension MeterCapabilities {
|
||
| 40 | 45 |
supportsRecordingView: true, |
| 41 | 46 |
supportsScreenSettings: true, |
| 42 | 47 |
supportsRecordingThreshold: true, |
| 48 |
+ reportsCurrentScreenIndex: true, |
|
| 49 |
+ showsDataGroupEnergy: true, |
|
| 50 |
+ highlightsActiveDataGroup: true, |
|
| 43 | 51 |
supportsFahrenheit: true, |
| 44 | 52 |
supportsChargerDetection: true, |
| 53 |
+ primaryTemperatureUnitSymbol: "℃", |
|
| 54 |
+ dataGroupsTitle: "Data Groups", |
|
| 45 | 55 |
chargerTypeDescriptions: [ |
| 46 | 56 |
1: "QC2", |
| 47 | 57 |
2: "QC3", |
@@ -60,21 +70,26 @@ extension MeterCapabilities {
|
||
| 60 | 70 |
4: "Graphing", |
| 61 | 71 |
5: "System Settings" |
| 62 | 72 |
], |
| 63 |
- dataGroupsHint: "Group 0 is temporary. Groups 1-9 persist across power cycles.", |
|
| 64 |
- recordingThresholdHint: "Recording starts automatically when current rises above this threshold." |
|
| 73 |
+ dataGroupsHint: "The active group is reported by the meter. Group 0 is temporary. Groups 1-9 persist across power cycles.", |
|
| 74 |
+ recordingThresholdHint: "The meter starts its built-in charge record when current rises above this threshold." |
|
| 65 | 75 |
) |
| 66 | 76 |
|
| 67 | 77 |
static let tc66c = MeterCapabilities( |
| 68 | 78 |
availableDataGroupIDs: [0, 1], |
| 69 | 79 |
supportsDataGroupCommands: false, |
| 70 |
- supportsRecordingView: false, |
|
| 80 |
+ supportsRecordingView: true, |
|
| 71 | 81 |
supportsScreenSettings: false, |
| 72 | 82 |
supportsRecordingThreshold: false, |
| 83 |
+ reportsCurrentScreenIndex: false, |
|
| 84 |
+ showsDataGroupEnergy: true, |
|
| 85 |
+ highlightsActiveDataGroup: false, |
|
| 73 | 86 |
supportsFahrenheit: false, |
| 74 | 87 |
supportsChargerDetection: false, |
| 88 |
+ primaryTemperatureUnitSymbol: nil, |
|
| 89 |
+ dataGroupsTitle: "Memory Totals", |
|
| 75 | 90 |
chargerTypeDescriptions: [:], |
| 76 | 91 |
screenDescriptions: [:], |
| 77 |
- dataGroupsHint: nil, |
|
| 92 |
+ dataGroupsHint: "The device exposes two read-only memories with charge and energy totals. The active memory is not reported.", |
|
| 78 | 93 |
recordingThresholdHint: nil |
| 79 | 94 |
) |
| 80 | 95 |
} |
@@ -89,7 +89,7 @@ enum TC66Protocol {
|
||
| 89 | 89 |
let offset = 8 + index * 8 |
| 90 | 90 |
dataGroupRecords[index] = TC66DataGroupTotals( |
| 91 | 91 |
ah: Double(UInt32(littleEndian: pac2.value(from: offset))) / 1000, |
| 92 |
- wh: Double(UInt32(littleEndian: pac2.value(from: offset + 40))) / 1000 |
|
| 92 |
+ wh: Double(UInt32(littleEndian: pac2.value(from: offset + 4))) / 1000 |
|
| 93 | 93 |
) |
| 94 | 94 |
} |
| 95 | 95 |
|
@@ -104,7 +104,7 @@ enum TC66Protocol {
|
||
| 104 | 104 |
voltage: Double(UInt32(littleEndian: pac1.value(from: 48))) / 10000, |
| 105 | 105 |
current: Double(UInt32(littleEndian: pac1.value(from: 52))) / 100000, |
| 106 | 106 |
power: Double(UInt32(littleEndian: pac1.value(from: 56))) / 10000, |
| 107 |
- loadResistance: Double(UInt32(littleEndian: pac2.value(from: 4))) / 10, |
|
| 107 |
+ loadResistance: Double(UInt32(littleEndian: pac2.value(from: 4))) / 100, |
|
| 108 | 108 |
dataGroupRecords: dataGroupRecords, |
| 109 | 109 |
temperatureCelsius: temperatureMagnitude * temperatureSign, |
| 110 | 110 |
usbPlusVoltage: Double(UInt32(littleEndian: pac2.value(from: 32))) / 100, |
@@ -23,6 +23,12 @@ struct ControlView: View {
|
||
| 23 | 23 |
Text(meter.currentScreenDescription) |
| 24 | 24 |
Button(action: { self.meter.nextScreen() }, label: { Image(systemName: "arrowtriangle.right") })
|
| 25 | 25 |
} |
| 26 |
+ if !meter.reportsCurrentScreenIndex {
|
|
| 27 |
+ Text("This protocol supports page navigation, but not current-page reporting.")
|
|
| 28 |
+ .font(.footnote) |
|
| 29 |
+ .foregroundColor(.secondary) |
|
| 30 |
+ .multilineTextAlignment(.center) |
|
| 31 |
+ } |
|
| 26 | 32 |
} |
| 27 | 33 |
} |
| 28 | 34 |
} |
@@ -14,14 +14,20 @@ struct DataGroupRowView: View {
|
||
| 14 | 14 |
var id: UInt8 |
| 15 | 15 |
var width: CGFloat |
| 16 | 16 |
var opacity: Double |
| 17 |
+ var showsCommands: Bool |
|
| 18 |
+ var showsEnergy: Bool |
|
| 19 |
+ var highlightsSelection: Bool |
|
| 17 | 20 |
|
| 18 | 21 |
@EnvironmentObject private var usbMeter: Meter |
| 19 | 22 |
|
| 20 | 23 |
var body: some View {
|
| 21 | 24 |
HStack (spacing: 1) {
|
| 22 | 25 |
ZStack {
|
| 23 |
- Button(action: { self.usbMeter.selectDataGroup(id: self.id) }, label: { Image(systemName: "\(id).circle") })
|
|
| 24 |
- .disabled(!usbMeter.supportsDataGroupCommands) |
|
| 26 |
+ if showsCommands {
|
|
| 27 |
+ Button(action: { self.usbMeter.selectDataGroup(id: self.id) }, label: { Image(systemName: "\(id).circle") })
|
|
| 28 |
+ } else {
|
|
| 29 |
+ Text(usbMeter.dataGroupLabel(for: id)) |
|
| 30 |
+ } |
|
| 25 | 31 |
Rectangle().opacity( opacity ) |
| 26 | 32 |
}.frame(width: width) |
| 27 | 33 |
|
@@ -30,17 +36,23 @@ struct DataGroupRowView: View {
|
||
| 30 | 36 |
Rectangle().opacity( opacity ) |
| 31 | 37 |
}.frame(width: width) |
| 32 | 38 |
|
| 33 |
- ZStack {
|
|
| 34 |
- Text("\(usbMeter.dataGroupRecords[Int(id)]!.wh.format(decimalDigits: 3))")
|
|
| 35 |
- Rectangle().opacity( opacity ) |
|
| 36 |
- }.frame(width: width) |
|
| 39 |
+ if showsEnergy {
|
|
| 40 |
+ ZStack {
|
|
| 41 |
+ Text("\(usbMeter.dataGroupRecords[Int(id)]!.wh.format(decimalDigits: 3))")
|
|
| 42 |
+ Rectangle().opacity( opacity ) |
|
| 43 |
+ }.frame(width: width) |
|
| 44 |
+ } |
|
| 37 | 45 |
|
| 38 |
- ZStack {
|
|
| 39 |
- Button(action: { self.usbMeter.clear(group: self.id) }, label: { Image(systemName: "bin.xmark") })
|
|
| 40 |
- .disabled(!usbMeter.supportsDataGroupCommands) |
|
| 41 |
- Rectangle().opacity( opacity ) |
|
| 42 |
- }.frame(width: width) |
|
| 46 |
+ if showsCommands {
|
|
| 47 |
+ ZStack {
|
|
| 48 |
+ Button(action: { self.usbMeter.clear(group: self.id) }, label: { Image(systemName: "bin.xmark") })
|
|
| 49 |
+ Rectangle().opacity( opacity ) |
|
| 50 |
+ }.frame(width: width) |
|
| 51 |
+ } |
|
| 43 | 52 |
} |
| 44 |
- .background(BorderView(show: usbMeter.selectedDataGroup == id)) |
|
| 53 |
+ .overlay( |
|
| 54 |
+ RoundedRectangle(cornerRadius: 10) |
|
| 55 |
+ .stroke(highlightsSelection && usbMeter.selectedDataGroup == id ? Color.accentColor : Color.clear, lineWidth: 3) |
|
| 56 |
+ ) |
|
| 45 | 57 |
} |
| 46 | 58 |
} |
@@ -16,13 +16,17 @@ struct DataGroupsView: View {
|
||
| 16 | 16 |
|
| 17 | 17 |
var body: some View {
|
| 18 | 18 |
GeometryReader { box in
|
| 19 |
+ let columnTitles = [usbMeter.supportsDataGroupCommands ? "Group" : "Memory", "Ah"] |
|
| 20 |
+ + (usbMeter.showsDataGroupEnergy ? ["Wh"] : []) |
|
| 21 |
+ + (usbMeter.supportsDataGroupCommands ? ["Clear"] : []) |
|
| 22 |
+ let columnWidth = (box.size.width - 25) / CGFloat(columnTitles.count) |
|
| 19 | 23 |
let tableReservedHeight: CGFloat = usbMeter.dataGroupsHint == nil ? 100 : 140 |
| 20 | 24 |
let rowCount = CGFloat(usbMeter.availableDataGroupIDs.count + 1) |
| 21 | 25 |
let rowHeight = (box.size.height - tableReservedHeight) / rowCount |
| 22 | 26 |
|
| 23 | 27 |
VStack (spacing: 1) {
|
| 24 | 28 |
HStack {
|
| 25 |
- Text("Data Groups")
|
|
| 29 |
+ Text(usbMeter.dataGroupsTitle) |
|
| 26 | 30 |
.bold() |
| 27 | 31 |
Spacer() |
| 28 | 32 |
Button(action: {self.visibility.toggle()}) {
|
@@ -43,13 +47,20 @@ struct DataGroupsView: View {
|
||
| 43 | 47 |
} |
| 44 | 48 |
|
| 45 | 49 |
HStack (spacing: 1) {
|
| 46 |
- ForEach (["Group", "Ah", "Wh", "Clear"], id: \.self ) { text in
|
|
| 47 |
- self.THView( text: text, width: (box.size.width-25)/4 ) |
|
| 50 |
+ ForEach(columnTitles, id: \.self ) { text in
|
|
| 51 |
+ self.THView(text: text, width: columnWidth) |
|
| 48 | 52 |
} |
| 49 | 53 |
} |
| 50 | 54 |
.frame(height: rowHeight) |
| 51 | 55 |
ForEach(usbMeter.availableDataGroupIDs, id: \.self) { groupId in
|
| 52 |
- DataGroupRowView(id: groupId, width: ((box.size.width-25) / 4), opacity: groupId.isMultiple(of: 2) ? 0.1 : 0.2) |
|
| 56 |
+ DataGroupRowView( |
|
| 57 |
+ id: groupId, |
|
| 58 |
+ width: columnWidth, |
|
| 59 |
+ opacity: groupId.isMultiple(of: 2) ? 0.1 : 0.2, |
|
| 60 |
+ showsCommands: usbMeter.supportsDataGroupCommands, |
|
| 61 |
+ showsEnergy: usbMeter.showsDataGroupEnergy, |
|
| 62 |
+ highlightsSelection: usbMeter.highlightsActiveDataGroup |
|
| 63 |
+ ) |
|
| 53 | 64 |
} |
| 54 | 65 |
.frame(height: rowHeight) |
| 55 | 66 |
} |
@@ -23,7 +23,7 @@ struct LiveView: View {
|
||
| 23 | 23 |
Text("Power:")
|
| 24 | 24 |
Text("Load")
|
| 25 | 25 |
Text("Temperature:")
|
| 26 |
- if meter.supportsFahrenheit {
|
|
| 26 |
+ if meter.secondaryTemperatureDescription != nil {
|
|
| 27 | 27 |
Text("")
|
| 28 | 28 |
} |
| 29 | 29 |
Text("USB Data+:")
|
@@ -49,9 +49,9 @@ struct LiveView: View {
|
||
| 49 | 49 |
Text("\(meter.measurements.power.context.maxValue.format(decimalDigits: 3))W")
|
| 50 | 50 |
} |
| 51 | 51 |
Text("\(meter.loadResistance.format(decimalDigits: 1))Ω")
|
| 52 |
- Text("\(meter.temperatureCelsius)℃")
|
|
| 53 |
- if meter.supportsFahrenheit {
|
|
| 54 |
- Text("\(meter.temperatureFahrenheit)℉")
|
|
| 52 |
+ Text(meter.primaryTemperatureDescription) |
|
| 53 |
+ if let secondaryTemperatureDescription = meter.secondaryTemperatureDescription {
|
|
| 54 |
+ Text(secondaryTemperatureDescription) |
|
| 55 | 55 |
} |
| 56 | 56 |
Text("\(meter.usbPlusVoltage.format(decimalDigits: 2))V")
|
| 57 | 57 |
Text("\(meter.usbMinusVoltage.format(decimalDigits: 2))V")
|
@@ -9,8 +9,13 @@ |
||
| 9 | 9 |
import SwiftUI |
| 10 | 10 |
|
| 11 | 11 |
struct MeasurementChartView: View {
|
| 12 |
+ private let minimumTimeSpan: TimeInterval = 1 |
|
| 13 |
+ private let minimumVoltageSpan = 0.1 |
|
| 14 |
+ private let minimumCurrentSpan = 0.1 |
|
| 15 |
+ private let minimumPowerSpan = 0.1 |
|
| 12 | 16 |
|
| 13 | 17 |
@EnvironmentObject private var measurements: Measurements |
| 18 |
+ var timeRange: ClosedRange<Date>? = nil |
|
| 14 | 19 |
|
| 15 | 20 |
@State var displayVoltage: Bool = false |
| 16 | 21 |
@State var displayCurrent: Bool = false |
@@ -19,8 +24,16 @@ struct MeasurementChartView: View {
|
||
| 19 | 24 |
let yLabels: Int = 4 |
| 20 | 25 |
|
| 21 | 26 |
var body: some View {
|
| 27 |
+ let powerSeries = series(for: measurements.power, minimumYSpan: minimumPowerSpan) |
|
| 28 |
+ let voltageSeries = series(for: measurements.voltage, minimumYSpan: minimumVoltageSpan) |
|
| 29 |
+ let currentSeries = series(for: measurements.current, minimumYSpan: minimumCurrentSpan) |
|
| 30 |
+ let primarySeries = displayedPrimarySeries( |
|
| 31 |
+ powerSeries: powerSeries, |
|
| 32 |
+ voltageSeries: voltageSeries, |
|
| 33 |
+ currentSeries: currentSeries |
|
| 34 |
+ ) |
|
| 35 |
+ |
|
| 22 | 36 |
Group {
|
| 23 |
- //if measurements.power.points.count > 0 {
|
|
| 24 | 37 |
VStack {
|
| 25 | 38 |
HStack {
|
| 26 | 39 |
Button( action: {
|
@@ -47,37 +60,34 @@ struct MeasurementChartView: View {
|
||
| 47 | 60 |
.asEnableFeatureButton(state: displayPower) |
| 48 | 61 |
} |
| 49 | 62 |
.padding(.bottom, 5) |
| 50 |
- if measurements.current.context.isValid {
|
|
| 63 |
+ if let primarySeries {
|
|
| 51 | 64 |
VStack {
|
| 52 | 65 |
GeometryReader { geometry in
|
| 53 | 66 |
HStack {
|
| 54 | 67 |
Group { // MARK: Left Legend
|
| 55 | 68 |
if self.displayPower {
|
| 56 |
- self.yAxisLabelsView(geometry: geometry, context: self.measurements.power.context, measurementUnit: "W") |
|
| 69 |
+ self.yAxisLabelsView(geometry: geometry, context: powerSeries.context, measurementUnit: "W") |
|
| 57 | 70 |
.withRoundedRectangleBackground(cornerRadius: 0, foregroundColor: .red, opacity: 0.5) |
| 58 | 71 |
} else if self.displayVoltage {
|
| 59 |
- self.yAxisLabelsView(geometry: geometry, context: self.measurements.voltage.context, measurementUnit: "V") |
|
| 72 |
+ self.yAxisLabelsView(geometry: geometry, context: voltageSeries.context, measurementUnit: "V") |
|
| 60 | 73 |
.withRoundedRectangleBackground(cornerRadius: 0, foregroundColor: .green, opacity: 0.5) |
| 61 | 74 |
} |
| 62 | 75 |
else if self.displayCurrent {
|
| 63 |
- self.yAxisLabelsView(geometry: geometry, context: self.measurements.current.context, measurementUnit: "A") |
|
| 76 |
+ self.yAxisLabelsView(geometry: geometry, context: currentSeries.context, measurementUnit: "A") |
|
| 64 | 77 |
.withRoundedRectangleBackground(cornerRadius: 0, foregroundColor: .blue, opacity: 0.5) |
| 65 | 78 |
} |
| 66 | 79 |
} |
| 67 | 80 |
ZStack { // MARK: Graph
|
| 68 | 81 |
if self.displayPower {
|
| 69 |
- Chart(strokeColor: .red) |
|
| 70 |
- .environmentObject(self.measurements.power) |
|
| 82 |
+ Chart(points: powerSeries.points, context: powerSeries.context, strokeColor: .red) |
|
| 71 | 83 |
.opacity(0.5) |
| 72 | 84 |
} else {
|
| 73 | 85 |
if self.displayVoltage{
|
| 74 |
- Chart(strokeColor: .green) |
|
| 75 |
- .environmentObject(self.measurements.voltage) |
|
| 86 |
+ Chart(points: voltageSeries.points, context: voltageSeries.context, strokeColor: .green) |
|
| 76 | 87 |
.opacity(0.5) |
| 77 | 88 |
} |
| 78 | 89 |
if self.displayCurrent{
|
| 79 |
- Chart(strokeColor: .blue) |
|
| 80 |
- .environmentObject(self.measurements.current) |
|
| 90 |
+ Chart(points: currentSeries.points, context: currentSeries.context, strokeColor: .blue) |
|
| 81 | 91 |
.opacity(0.5) |
| 82 | 92 |
} |
| 83 | 93 |
} |
@@ -88,13 +98,13 @@ struct MeasurementChartView: View {
|
||
| 88 | 98 |
} |
| 89 | 99 |
.withRoundedRectangleBackground( cornerRadius: 0, foregroundColor: .primary, opacity: 0.06 ) |
| 90 | 100 |
Group { // MARK: Right Legend
|
| 91 |
- self.yAxisLabelsView(geometry: geometry, context: self.measurements.current.context, measurementUnit: "A") |
|
| 101 |
+ self.yAxisLabelsView(geometry: geometry, context: currentSeries.context, measurementUnit: "A") |
|
| 92 | 102 |
.foregroundColor(self.displayVoltage && self.displayCurrent ? .primary : .clear) |
| 93 | 103 |
.withRoundedRectangleBackground(cornerRadius: 0, foregroundColor: .blue, opacity: 0.5) |
| 94 | 104 |
} |
| 95 | 105 |
} |
| 96 | 106 |
} |
| 97 |
- xAxisLabelsView(context: self.measurements.current.context) |
|
| 107 |
+ xAxisLabelsView(context: primarySeries.context) |
|
| 98 | 108 |
.padding(.horizontal, 10) |
| 99 | 109 |
|
| 100 | 110 |
} |
@@ -111,6 +121,44 @@ struct MeasurementChartView: View {
|
||
| 111 | 121 |
.padding() |
| 112 | 122 |
} |
| 113 | 123 |
} |
| 124 |
+ |
|
| 125 |
+ private func displayedPrimarySeries( |
|
| 126 |
+ powerSeries: (points: [Measurements.Measurement.Point], context: ChartContext), |
|
| 127 |
+ voltageSeries: (points: [Measurements.Measurement.Point], context: ChartContext), |
|
| 128 |
+ currentSeries: (points: [Measurements.Measurement.Point], context: ChartContext) |
|
| 129 |
+ ) -> (points: [Measurements.Measurement.Point], context: ChartContext)? {
|
|
| 130 |
+ if displayPower {
|
|
| 131 |
+ return powerSeries.points.isEmpty ? nil : powerSeries |
|
| 132 |
+ } |
|
| 133 |
+ if displayVoltage {
|
|
| 134 |
+ return voltageSeries.points.isEmpty ? nil : voltageSeries |
|
| 135 |
+ } |
|
| 136 |
+ if displayCurrent {
|
|
| 137 |
+ return currentSeries.points.isEmpty ? nil : currentSeries |
|
| 138 |
+ } |
|
| 139 |
+ return nil |
|
| 140 |
+ } |
|
| 141 |
+ |
|
| 142 |
+ private func series( |
|
| 143 |
+ for measurement: Measurements.Measurement, |
|
| 144 |
+ minimumYSpan: Double |
|
| 145 |
+ ) -> (points: [Measurements.Measurement.Point], context: ChartContext) {
|
|
| 146 |
+ let points = measurement.points.filter { point in
|
|
| 147 |
+ guard let timeRange else { return true }
|
|
| 148 |
+ return timeRange.contains(point.timestamp) |
|
| 149 |
+ } |
|
| 150 |
+ let context = ChartContext() |
|
| 151 |
+ for point in points {
|
|
| 152 |
+ context.include(point: point.point()) |
|
| 153 |
+ } |
|
| 154 |
+ if !points.isEmpty {
|
|
| 155 |
+ context.ensureMinimumSize( |
|
| 156 |
+ width: CGFloat(minimumTimeSpan), |
|
| 157 |
+ height: CGFloat(minimumYSpan) |
|
| 158 |
+ ) |
|
| 159 |
+ } |
|
| 160 |
+ return (points, context) |
|
| 161 |
+ } |
|
| 114 | 162 |
|
| 115 | 163 |
// MARK: Cu iteratie nu functioneaza deoarece view-ul din bucla nu este reimprospatat la modificare obiectului observat |
| 116 | 164 |
fileprivate func xAxisLabelsView(context: ChartContext) -> some View {
|
@@ -189,7 +237,8 @@ struct MeasurementChartView: View {
|
||
| 189 | 237 |
|
| 190 | 238 |
struct Chart : View {
|
| 191 | 239 |
|
| 192 |
- @EnvironmentObject private var measurement: Measurements.Measurement |
|
| 240 |
+ let points: [Measurements.Measurement.Point] |
|
| 241 |
+ let context: ChartContext |
|
| 193 | 242 |
var areaChart: Bool = false |
| 194 | 243 |
var strokeColor: Color = .black |
| 195 | 244 |
|
@@ -207,14 +256,15 @@ struct Chart : View {
|
||
| 207 | 256 |
|
| 208 | 257 |
fileprivate func path(geometry: GeometryProxy) -> Path {
|
| 209 | 258 |
return Path { path in
|
| 210 |
- let firstPoint = measurement.context.placeInRect(point: measurement.points.first!.point()) |
|
| 259 |
+ guard let first = points.first else { return }
|
|
| 260 |
+ let firstPoint = context.placeInRect(point: first.point()) |
|
| 211 | 261 |
path.move(to: CGPoint(x: firstPoint.x * geometry.size.width, y: firstPoint.y * geometry.size.height ) ) |
| 212 |
- for item in measurement.points.map({ measurement.context.placeInRect(point: $0.point()) }) {
|
|
| 262 |
+ for item in points.map({ context.placeInRect(point: $0.point()) }) {
|
|
| 213 | 263 |
path.addLine(to: CGPoint(x: item.x * geometry.size.width, y: item.y * geometry.size.height ) ) |
| 214 | 264 |
} |
| 215 | 265 |
if self.areaChart {
|
| 216 |
- let lastPointX = measurement.context.placeInRect(point: CGPoint(x: measurement.points.last!.point().x, y: measurement.context.origin.y )) |
|
| 217 |
- let firstPointX = measurement.context.placeInRect(point: CGPoint(x: measurement.points.first!.point().x, y: measurement.context.origin.y )) |
|
| 266 |
+ let lastPointX = context.placeInRect(point: CGPoint(x: points.last!.point().x, y: context.origin.y )) |
|
| 267 |
+ let firstPointX = context.placeInRect(point: CGPoint(x: points.first!.point().x, y: context.origin.y )) |
|
| 218 | 268 |
path.addLine(to: CGPoint(x: lastPointX.x * geometry.size.width, y: lastPointX.y * geometry.size.height ) ) |
| 219 | 269 |
path.addLine(to: CGPoint(x: firstPointX.x * geometry.size.width, y: firstPointX.y * geometry.size.height ) ) |
| 220 | 270 |
// MARK: Nu e nevoie. Fill inchide automat calea |
@@ -40,6 +40,7 @@ struct MeterSettingsView: View {
|
||
| 40 | 40 |
Text("Device Info").fontWeight(.semibold)
|
| 41 | 41 |
DeviceInfoRow(label: "Advertised Model", value: meter.modelString) |
| 42 | 42 |
DeviceInfoRow(label: "Displayed Model", value: meter.deviceModelSummary) |
| 43 |
+ DeviceInfoRow(label: "Temperature Unit", value: meter.temperatureUnitDescription) |
|
| 43 | 44 |
if meter.modelNumber != 0 {
|
| 44 | 45 |
DeviceInfoRow(label: "Model Code", value: "\(meter.modelNumber)") |
| 45 | 46 |
} |
@@ -56,6 +57,22 @@ struct MeterSettingsView: View {
|
||
| 56 | 57 |
.padding() |
| 57 | 58 |
.background(RoundedRectangle(cornerRadius: 15).foregroundColor(.secondary).opacity(0.1)) |
| 58 | 59 |
} |
| 60 |
+ if meter.operationalState == .dataIsAvailable && meter.supportsManualTemperatureUnitSelection {
|
|
| 61 |
+ VStack(alignment: .leading, spacing: 8) {
|
|
| 62 |
+ Text("Temperature Unit").fontWeight(.semibold)
|
|
| 63 |
+ Text("TC66 reports temperature using the unit selected on the device. Keep this setting matched to the meter.")
|
|
| 64 |
+ .font(.footnote) |
|
| 65 |
+ .foregroundColor(.secondary) |
|
| 66 |
+ Picker("", selection: $meter.tc66TemperatureUnitPreference) {
|
|
| 67 |
+ ForEach(TemperatureUnitPreference.allCases) { unit in
|
|
| 68 |
+ Text(unit.title).tag(unit) |
|
| 69 |
+ } |
|
| 70 |
+ } |
|
| 71 |
+ .pickerStyle(SegmentedPickerStyle()) |
|
| 72 |
+ } |
|
| 73 |
+ .padding() |
|
| 74 |
+ .background(RoundedRectangle(cornerRadius: 15).foregroundColor(.secondary).opacity(0.1)) |
|
| 75 |
+ } |
|
| 59 | 76 |
if meter.operationalState == .dataIsAvailable && meter.supportsUMSettings {
|
| 60 | 77 |
// MARK: Screen Timeout |
| 61 | 78 |
// Ar trebui separat enabled/disabled de valorile in minute eventual stocata valoarea in iCloud la dezactivare pentru restaurare |
@@ -45,7 +45,7 @@ struct MeterView: View {
|
||
| 45 | 45 |
if ( meter.operationalState == .dataIsAvailable) {
|
| 46 | 46 |
Text("Model: \(meter.deviceModelSummary)")
|
| 47 | 47 |
HStack(spacing: 24) {
|
| 48 |
- meterSheetButton(icon: "map", title: "Data Groups") {
|
|
| 48 |
+ meterSheetButton(icon: "map", title: meter.dataGroupsTitle) {
|
|
| 49 | 49 |
dataGroupsViewVisibility.toggle() |
| 50 | 50 |
} |
| 51 | 51 |
.sheet(isPresented: self.$dataGroupsViewVisibility) {
|
@@ -54,7 +54,7 @@ struct MeterView: View {
|
||
| 54 | 54 |
} |
| 55 | 55 |
|
| 56 | 56 |
if meter.supportsRecordingView {
|
| 57 |
- meterSheetButton(icon: "record.circle", title: "Recording") {
|
|
| 57 |
+ meterSheetButton(icon: "record.circle", title: "Charge Record") {
|
|
| 58 | 58 |
recordingViewVisibility.toggle() |
| 59 | 59 |
} |
| 60 | 60 |
.sheet(isPresented: self.$recordingViewVisibility) {
|
@@ -18,10 +18,10 @@ struct RecordingView: View {
|
||
| 18 | 18 |
ScrollView {
|
| 19 | 19 |
VStack(spacing: 16) {
|
| 20 | 20 |
VStack(spacing: 6) {
|
| 21 |
- Text("Device Recording")
|
|
| 21 |
+ Text("Charge Record")
|
|
| 22 | 22 |
.font(.headline) |
| 23 |
- Text(usbMeter.recording ? "Active" : "Idle") |
|
| 24 |
- .foregroundColor(usbMeter.recording ? .red : .secondary) |
|
| 23 |
+ Text(usbMeter.chargeRecordStatusText) |
|
| 24 |
+ .foregroundColor(usbMeter.chargeRecordStatusColor) |
|
| 25 | 25 |
} |
| 26 | 26 |
.frame(maxWidth: .infinity) |
| 27 | 27 |
.padding() |
@@ -32,48 +32,95 @@ struct RecordingView: View {
|
||
| 32 | 32 |
Text("Capacity")
|
| 33 | 33 |
Text("Energy")
|
| 34 | 34 |
Text("Duration")
|
| 35 |
- Text("Start Threshold")
|
|
| 35 |
+ Text("Stop Threshold")
|
|
| 36 | 36 |
} |
| 37 | 37 |
Spacer() |
| 38 | 38 |
VStack(alignment: .trailing, spacing: 10) {
|
| 39 |
- Text("\(usbMeter.recordedAH.format(decimalDigits: 3)) Ah")
|
|
| 40 |
- Text("\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh")
|
|
| 41 |
- Text(usbMeter.recordingDurationDescription) |
|
| 42 |
- if usbMeter.supportsRecordingThreshold {
|
|
| 43 |
- Text("\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A")
|
|
| 44 |
- } else {
|
|
| 45 |
- Text("Read-only")
|
|
| 46 |
- } |
|
| 39 |
+ Text("\(usbMeter.chargeRecordAH.format(decimalDigits: 3)) Ah")
|
|
| 40 |
+ Text("\(usbMeter.chargeRecordWH.format(decimalDigits: 3)) Wh")
|
|
| 41 |
+ Text(usbMeter.chargeRecordDurationDescription) |
|
| 42 |
+ Text("\(usbMeter.chargeRecordStopThreshold.format(decimalDigits: 2)) A")
|
|
| 47 | 43 |
} |
| 48 | 44 |
} |
| 49 | 45 |
.padding() |
| 50 | 46 |
.background(RoundedRectangle(cornerRadius: 16).foregroundColor(.secondary).opacity(0.1)) |
| 51 | 47 |
|
| 52 |
- if usbMeter.supportsRecordingThreshold {
|
|
| 53 |
- VStack(alignment: .leading, spacing: 10) {
|
|
| 54 |
- Text("Start Threshold")
|
|
| 55 |
- .fontWeight(.semibold) |
|
| 56 |
- Slider(value: $usbMeter.recordingTreshold, in: 0...0.30, step: 0.01) |
|
| 57 |
- if let hint = usbMeter.recordingThresholdHint {
|
|
| 58 |
- Text(hint) |
|
| 59 |
- .font(.footnote) |
|
| 60 |
- .foregroundColor(.secondary) |
|
| 48 |
+ if usbMeter.chargeRecordTimeRange != nil {
|
|
| 49 |
+ VStack(alignment: .leading, spacing: 12) {
|
|
| 50 |
+ HStack {
|
|
| 51 |
+ Text("Charge Curve")
|
|
| 52 |
+ .fontWeight(.semibold) |
|
| 53 |
+ Spacer() |
|
| 54 |
+ Button("Reset Graph") {
|
|
| 55 |
+ usbMeter.resetChargeRecordGraph() |
|
| 56 |
+ } |
|
| 61 | 57 |
} |
| 58 |
+ MeasurementChartView(timeRange: usbMeter.chargeRecordTimeRange) |
|
| 59 |
+ .environmentObject(usbMeter.measurements) |
|
| 60 |
+ .frame(minHeight: 220) |
|
| 61 |
+ Text("Reset Graph clears the current charge-record session and removes older shared samples that are no longer needed for this curve.")
|
|
| 62 |
+ .font(.footnote) |
|
| 63 |
+ .foregroundColor(.secondary) |
|
| 62 | 64 |
} |
| 63 | 65 |
.padding() |
| 64 | 66 |
.background(RoundedRectangle(cornerRadius: 16).foregroundColor(.secondary).opacity(0.1)) |
| 65 |
- } else {
|
|
| 66 |
- Text("This model reports recording totals, but the app does not expose remote threshold control for it.")
|
|
| 67 |
+ } |
|
| 68 |
+ |
|
| 69 |
+ VStack(alignment: .leading, spacing: 12) {
|
|
| 70 |
+ Text("Stop Threshold")
|
|
| 71 |
+ .fontWeight(.semibold) |
|
| 72 |
+ Slider(value: $usbMeter.chargeRecordStopThreshold, in: 0...0.30, step: 0.01) |
|
| 73 |
+ Text("The app starts accumulating when current rises above this threshold and stops when it falls back to or below it.")
|
|
| 67 | 74 |
.font(.footnote) |
| 68 | 75 |
.foregroundColor(.secondary) |
| 69 |
- .multilineTextAlignment(.center) |
|
| 70 |
- .padding() |
|
| 71 |
- .background(RoundedRectangle(cornerRadius: 16).foregroundColor(.secondary).opacity(0.1)) |
|
| 76 |
+ Button("Reset") {
|
|
| 77 |
+ usbMeter.resetChargeRecord() |
|
| 78 |
+ } |
|
| 79 |
+ .frame(maxWidth: .infinity) |
|
| 80 |
+ } |
|
| 81 |
+ .padding() |
|
| 82 |
+ .background(RoundedRectangle(cornerRadius: 16).foregroundColor(.secondary).opacity(0.1)) |
|
| 83 |
+ |
|
| 84 |
+ if usbMeter.supportsDataGroupCommands || usbMeter.recordedAH > 0 || usbMeter.recordedWH > 0 || usbMeter.recordingDuration > 0 {
|
|
| 85 |
+ VStack(alignment: .leading, spacing: 12) {
|
|
| 86 |
+ Text("Meter Totals")
|
|
| 87 |
+ .fontWeight(.semibold) |
|
| 88 |
+ HStack(alignment: .top) {
|
|
| 89 |
+ VStack(alignment: .leading, spacing: 10) {
|
|
| 90 |
+ Text("Capacity")
|
|
| 91 |
+ Text("Energy")
|
|
| 92 |
+ Text("Duration")
|
|
| 93 |
+ Text("Meter Threshold")
|
|
| 94 |
+ } |
|
| 95 |
+ Spacer() |
|
| 96 |
+ VStack(alignment: .trailing, spacing: 10) {
|
|
| 97 |
+ Text("\(usbMeter.recordedAH.format(decimalDigits: 3)) Ah")
|
|
| 98 |
+ Text("\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh")
|
|
| 99 |
+ Text(usbMeter.recordingDurationDescription) |
|
| 100 |
+ if usbMeter.supportsRecordingThreshold {
|
|
| 101 |
+ Text("\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A")
|
|
| 102 |
+ } else {
|
|
| 103 |
+ Text("Read-only")
|
|
| 104 |
+ } |
|
| 105 |
+ } |
|
| 106 |
+ } |
|
| 107 |
+ Text("These values are reported by the meter for the active data group.")
|
|
| 108 |
+ .font(.footnote) |
|
| 109 |
+ .foregroundColor(.secondary) |
|
| 110 |
+ if usbMeter.supportsDataGroupCommands {
|
|
| 111 |
+ Button("Reset Active Group") {
|
|
| 112 |
+ usbMeter.clear() |
|
| 113 |
+ } |
|
| 114 |
+ .frame(maxWidth: .infinity) |
|
| 115 |
+ } |
|
| 116 |
+ } |
|
| 117 |
+ .padding() |
|
| 118 |
+ .background(RoundedRectangle(cornerRadius: 16).foregroundColor(.secondary).opacity(0.1)) |
|
| 72 | 119 |
} |
| 73 | 120 |
} |
| 74 | 121 |
.padding() |
| 75 | 122 |
} |
| 76 |
- .navigationBarTitle("Device Recording", displayMode: .inline)
|
|
| 123 |
+ .navigationBarTitle("Charge Record", displayMode: .inline)
|
|
| 77 | 124 |
.navigationBarItems(trailing: Button("Done") { visibility.toggle() })
|
| 78 | 125 |
} |
| 79 | 126 |
.navigationViewStyle(StackNavigationViewStyle()) |