@@ -32,6 +32,8 @@ |
||
| 32 | 32 |
4383B468240F845500DAAEBF /* MacAdress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4383B467240F845500DAAEBF /* MacAdress.swift */; };
|
| 33 | 33 |
4383B46A240FE4A600DAAEBF /* MeterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4383B469240FE4A600DAAEBF /* MeterView.swift */; };
|
| 34 | 34 |
438695892463F062008855A9 /* Measurements.swift in Sources */ = {isa = PBXBuildFile; fileRef = 438695882463F062008855A9 /* Measurements.swift */; };
|
| 35 |
+ 4386958B2F6A1001008855A9 /* UMProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4386958A2F6A1001008855A9 /* UMProtocol.swift */; };
|
|
| 36 |
+ 4386958D2F6A1002008855A9 /* TC66Protocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4386958C2F6A1002008855A9 /* TC66Protocol.swift */; };
|
|
| 35 | 37 |
43874C7F2414F3F400525397 /* Float.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43874C7E2414F3F400525397 /* Float.swift */; };
|
| 36 | 38 |
43874C83241533AD00525397 /* Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43874C82241533AD00525397 /* Data.swift */; };
|
| 37 | 39 |
43874C852415611200525397 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43874C842415611200525397 /* Double.swift */; };
|
@@ -107,6 +109,8 @@ |
||
| 107 | 109 |
4383B467240F845500DAAEBF /* MacAdress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacAdress.swift; sourceTree = "<group>"; };
|
| 108 | 110 |
4383B469240FE4A600DAAEBF /* MeterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterView.swift; sourceTree = "<group>"; };
|
| 109 | 111 |
438695882463F062008855A9 /* Measurements.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Measurements.swift; sourceTree = "<group>"; };
|
| 112 |
+ 4386958A2F6A1001008855A9 /* UMProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UMProtocol.swift; sourceTree = "<group>"; };
|
|
| 113 |
+ 4386958C2F6A1002008855A9 /* TC66Protocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TC66Protocol.swift; sourceTree = "<group>"; };
|
|
| 110 | 114 |
43874C7E2414F3F400525397 /* Float.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Float.swift; sourceTree = "<group>"; };
|
| 111 | 115 |
43874C82241533AD00525397 /* Data.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data.swift; sourceTree = "<group>"; };
|
| 112 | 116 |
43874C842415611200525397 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = "<group>"; };
|
@@ -355,6 +359,8 @@ |
||
| 355 | 359 |
4383B45F240EB2D000DAAEBF /* Meter.swift */, |
| 356 | 360 |
43ED78AD2420A0BE00974487 /* BluetoothSerial.swift */, |
| 357 | 361 |
439D996424234B98008DE3AA /* BluetoothRadio.swift */, |
| 362 |
+ 4386958A2F6A1001008855A9 /* UMProtocol.swift */, |
|
| 363 |
+ 4386958C2F6A1002008855A9 /* TC66Protocol.swift */, |
|
| 358 | 364 |
43CBF663240BF3EB00255B8B /* CKModel.xcdatamodeld */, |
| 359 | 365 |
438695882463F062008855A9 /* Measurements.swift */, |
| 360 | 366 |
432EA6432445A559006FC905 /* ChartContext.swift */, |
@@ -489,6 +495,7 @@ |
||
| 489 | 495 |
437D47D32415FB7E00B7768E /* Decimal.swift in Sources */, |
| 490 | 496 |
43874C7F2414F3F400525397 /* Float.swift in Sources */, |
| 491 | 497 |
4383B462240EB5E400DAAEBF /* AppData.swift in Sources */, |
| 498 |
+ 4386958D2F6A1002008855A9 /* TC66Protocol.swift in Sources */, |
|
| 492 | 499 |
437D47D52415FD8C00B7768E /* RecordingView.swift in Sources */, |
| 493 | 500 |
432EA6442445A559006FC905 /* ChartContext.swift in Sources */, |
| 494 | 501 |
4308CF8624176CAB0002E80B /* DataGroupRowView.swift in Sources */, |
@@ -504,6 +511,7 @@ |
||
| 504 | 511 |
4311E63A241384960080EA59 /* DeviceHelpView.swift in Sources */, |
| 505 | 512 |
439D996524234B98008DE3AA /* BluetoothRadio.swift in Sources */, |
| 506 | 513 |
438695892463F062008855A9 /* Measurements.swift in Sources */, |
| 514 |
+ 4386958B2F6A1001008855A9 /* UMProtocol.swift in Sources */, |
|
| 507 | 515 |
43874C83241533AD00525397 /* Data.swift in Sources */, |
| 508 | 516 |
); |
| 509 | 517 |
runOnlyForDeploymentPostprocessing = 0; |
@@ -142,6 +142,35 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 142 | 142 |
} |
| 143 | 143 |
} |
| 144 | 144 |
|
| 145 |
+ var availableDataGroupIDs: [UInt8] {
|
|
| 146 |
+ switch model {
|
|
| 147 |
+ case .UM25C, .UM34C: |
|
| 148 |
+ return Array(0...9) |
|
| 149 |
+ case .TC66C: |
|
| 150 |
+ return [0, 1] |
|
| 151 |
+ } |
|
| 152 |
+ } |
|
| 153 |
+ |
|
| 154 |
+ var supportsDataGroupCommands: Bool {
|
|
| 155 |
+ model != .TC66C |
|
| 156 |
+ } |
|
| 157 |
+ |
|
| 158 |
+ var supportsUMSettings: Bool {
|
|
| 159 |
+ model != .TC66C |
|
| 160 |
+ } |
|
| 161 |
+ |
|
| 162 |
+ var supportsRecordingThreshold: Bool {
|
|
| 163 |
+ model != .TC66C |
|
| 164 |
+ } |
|
| 165 |
+ |
|
| 166 |
+ var supportsFahrenheit: Bool {
|
|
| 167 |
+ model != .TC66C |
|
| 168 |
+ } |
|
| 169 |
+ |
|
| 170 |
+ var supportsChargerDetection: Bool {
|
|
| 171 |
+ model != .TC66C |
|
| 172 |
+ } |
|
| 173 |
+ |
|
| 145 | 174 |
@Published var btSerial: BluetoothSerial |
| 146 | 175 |
|
| 147 | 176 |
@Published var measurements = Measurements() |
@@ -227,11 +256,11 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 227 | 256 |
if commandQueue.isEmpty {
|
| 228 | 257 |
switch model {
|
| 229 | 258 |
case .UM25C: |
| 230 |
- btSerial.write( Data([0xF0]), expectedResponseLength: 130) |
|
| 259 |
+ btSerial.write(UMProtocol.snapshotRequest, expectedResponseLength: 130) |
|
| 231 | 260 |
case .UM34C: |
| 232 |
- btSerial.write( Data([0xF0]), expectedResponseLength: 130) |
|
| 261 |
+ btSerial.write(UMProtocol.snapshotRequest, expectedResponseLength: 130) |
|
| 233 | 262 |
case .TC66C: |
| 234 |
- btSerial.write( "bgetva\r\n".data(using: String.Encoding.ascii)!, expectedResponseLength: 192) |
|
| 263 |
+ btSerial.write(TC66Protocol.snapshotRequest, expectedResponseLength: 192) |
|
| 235 | 264 |
} |
| 236 | 265 |
dataDumpRequestTimestamp = Date() |
| 237 | 266 |
// track("\(name) - Request sent!")
|
@@ -254,11 +283,23 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 254 | 283 |
//track("\(name)")
|
| 255 | 284 |
switch model {
|
| 256 | 285 |
case .UM25C: |
| 257 |
- parseUMData(from: buffer) |
|
| 286 |
+ do {
|
|
| 287 |
+ apply(umSnapshot: try UMProtocol.parseSnapshot(from: buffer, model: model)) |
|
| 288 |
+ } catch {
|
|
| 289 |
+ track("\(name) - Error: \(error)")
|
|
| 290 |
+ } |
|
| 258 | 291 |
case .UM34C: |
| 259 |
- parseUMData(from: buffer) |
|
| 292 |
+ do {
|
|
| 293 |
+ apply(umSnapshot: try UMProtocol.parseSnapshot(from: buffer, model: model)) |
|
| 294 |
+ } catch {
|
|
| 295 |
+ track("\(name) - Error: \(error)")
|
|
| 296 |
+ } |
|
| 260 | 297 |
case .TC66C: |
| 261 |
- parseTCData( from: buffer ) |
|
| 298 |
+ do {
|
|
| 299 |
+ apply(tc66Snapshot: try TC66Protocol.parseSnapshot(from: buffer)) |
|
| 300 |
+ } catch {
|
|
| 301 |
+ track("\(name) - Error: \(error)")
|
|
| 302 |
+ } |
|
| 262 | 303 |
} |
| 263 | 304 |
measurements.addValues(timestamp: dataDumpRequestTimestamp, power: power, voltage: voltage, current: current) |
| 264 | 305 |
// DispatchQueue.global(qos: .userInitiated).asyncAfter( deadline: .now() + 0.33 ) {
|
@@ -267,179 +308,125 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 267 | 308 |
operationalState = .dataIsAvailable |
| 268 | 309 |
dataDumpRequest() |
| 269 | 310 |
} |
| 270 |
- |
|
| 271 |
- func parseUMData(from buffer: Data) {
|
|
| 272 |
- modelNumber = UInt16( bigEndian: buffer.value( from: 0 ) ) |
|
| 273 |
- switch model {
|
|
| 274 |
- case .UM25C: |
|
| 275 |
- voltage = Double( UInt16( bigEndian: buffer.value( from: 2) ) )/1000 |
|
| 276 |
- current = Double( UInt16( bigEndian: buffer.value( from: 4) ) )/10000 |
|
| 277 |
- case .UM34C: |
|
| 278 |
- voltage = Double( UInt16( bigEndian: buffer.value( from: 2) ) )/100 |
|
| 279 |
- current = Double( UInt16( bigEndian: buffer.value( from: 4) ) )/1000 |
|
| 280 |
- case .TC66C: |
|
| 281 |
- track("\(name) - This is not possible!")
|
|
| 282 | 311 |
|
| 312 |
+ private func apply(umSnapshot snapshot: UMSnapshot) {
|
|
| 313 |
+ modelNumber = snapshot.modelNumber |
|
| 314 |
+ voltage = snapshot.voltage |
|
| 315 |
+ current = snapshot.current |
|
| 316 |
+ power = snapshot.power |
|
| 317 |
+ temperatureCelsius = snapshot.temperatureCelsius |
|
| 318 |
+ temperatureFahrenheit = snapshot.temperatureFahrenheit |
|
| 319 |
+ selectedDataGroup = snapshot.selectedDataGroup |
|
| 320 |
+ for (index, record) in snapshot.dataGroupRecords {
|
|
| 321 |
+ dataGroupRecords[index] = DataGroupRecord(ah: record.ah, wh: record.wh) |
|
| 283 | 322 |
} |
| 284 |
- power = Double( UInt32( bigEndian: buffer.value( from: 6) ) )/1000 |
|
| 285 |
- temperatureCelsius = Double( UInt16( bigEndian: buffer.value( from: 10) ) ) |
|
| 286 |
- temperatureFahrenheit = Double( UInt16( bigEndian: buffer.value( from: 12) ) ) |
|
| 287 |
- selectedDataGroup = UInt8(UInt16( bigEndian: buffer.value( from: 14) ) ) |
|
| 288 |
- for index in stride(from: 0, through: 9, by: 1) {
|
|
| 289 |
- let offset = 16 + index * 8 |
|
| 290 |
- dataGroupRecords[index] = DataGroupRecord(ah: Double (UInt32( bigEndian: buffer.value( from: offset ) ) )/1000, wh:Double (UInt32( bigEndian: buffer.value( from: offset + 4 ) ) )/1000) |
|
| 291 |
- } |
|
| 292 |
- usbPlusVoltage = Double( UInt16( bigEndian: buffer.value( from: 96) ) )/100 |
|
| 293 |
- usbMinusVoltage = Double( UInt16( bigEndian: buffer.value( from: 98) ) )/100 |
|
| 294 |
- chargerTypeIndex = UInt16( bigEndian: buffer.value( from: 100) ) |
|
| 295 |
- recordedAH = Double (UInt32( bigEndian: buffer.value( from: 102 ) ) )/1000 |
|
| 296 |
- recordedWH = Double (UInt32( bigEndian: buffer.value( from: 106 ) ) )/1000 |
|
| 297 |
- recordingTreshold = Double (UInt16( bigEndian: buffer.value( from: 110 ) ) )/100 |
|
| 298 |
- recordingDuration = UInt32( bigEndian: buffer.value( from: 112 ) ) |
|
| 299 |
- recording = UInt16( bigEndian: buffer.value( from: 116 ) ) == 1 |
|
| 323 |
+ usbPlusVoltage = snapshot.usbPlusVoltage |
|
| 324 |
+ usbMinusVoltage = snapshot.usbMinusVoltage |
|
| 325 |
+ chargerTypeIndex = snapshot.chargerTypeIndex |
|
| 326 |
+ recordedAH = snapshot.recordedAH |
|
| 327 |
+ recordedWH = snapshot.recordedWH |
|
| 328 |
+ recordingTreshold = snapshot.recordingThreshold |
|
| 329 |
+ recordingDuration = snapshot.recordingDuration |
|
| 330 |
+ recording = snapshot.recording |
|
| 331 |
+ |
|
| 300 | 332 |
if screenTimeoutTimestamp < dataDumpRequestTimestamp {
|
| 301 |
- let tmpValue = Int (UInt16( bigEndian: buffer.value( from: 118 ) ) ) |
|
| 302 |
- if screenTimeout != tmpValue {
|
|
| 303 |
- screenTimeout = tmpValue |
|
| 333 |
+ if screenTimeout != snapshot.screenTimeout {
|
|
| 334 |
+ screenTimeout = snapshot.screenTimeout |
|
| 304 | 335 |
} |
| 305 | 336 |
} else {
|
| 306 | 337 |
track("\(name) - Skip updating screenTimeout (changed after request).")
|
| 307 | 338 |
} |
| 308 | 339 |
|
| 309 | 340 |
if screenBrightnessTimestamp < dataDumpRequestTimestamp {
|
| 310 |
- let tmpValue = Int (UInt16( bigEndian: buffer.value( from: 120 ) ) ) |
|
| 311 |
- if screenBrightness != tmpValue {
|
|
| 312 |
- screenBrightness = tmpValue |
|
| 341 |
+ if screenBrightness != snapshot.screenBrightness {
|
|
| 342 |
+ screenBrightness = snapshot.screenBrightness |
|
| 313 | 343 |
} |
| 314 | 344 |
} else {
|
| 315 | 345 |
track("\(name) - Skip updating screenBrightness (changed after request).")
|
| 316 | 346 |
} |
| 317 | 347 |
|
| 318 |
- currentScreen = UInt16(bigEndian: buffer.value( from: 126 ) ) |
|
| 319 |
- loadResistance = Double ( UInt32( bigEndian: buffer.value( from: 122 ) ) )/10 |
|
| 320 |
- // track("\(name) - Model Number = \(modelNumber)")
|
|
| 321 |
- // track("\(name) - chargerTypeIndex = \(chargerTypeIndex)")
|
|
| 348 |
+ currentScreen = snapshot.currentScreen |
|
| 349 |
+ loadResistance = snapshot.loadResistance |
|
| 322 | 350 |
} |
| 323 |
- |
|
| 324 |
- private func validatePac ( id: UInt8, pac: Data ) -> Bool {
|
|
| 325 |
- //track("\(name) - \(id) - \(pac.hexEncodedStringValue)")
|
|
| 326 |
- let expectedHeader = "pac\(id)".data(using: String.Encoding.ascii) |
|
| 327 |
- let pacHeader = pac.subdata(from: 0, length: 4) |
|
| 328 |
- let expectedCRC = UInt16( bigEndian: pac.subdata(from: 0, length: 60).crc16(seed: 0xFFFF).value( from: 0 ) ) |
|
| 329 |
- let pacCRC = UInt16( littleEndian: pac.value(from: 60) ) |
|
| 330 |
- return expectedHeader == pacHeader && expectedCRC == pacCRC |
|
| 331 |
- } |
|
| 332 |
- |
|
| 333 |
- func parseTCData(from buffer: Data) {
|
|
| 334 |
- do {
|
|
| 335 |
- let key: [UInt8] = [ |
|
| 336 |
- 0x58, 0x21, 0xfa, 0x56, 0x01, 0xb2, 0xf0, 0x26, |
|
| 337 |
- 0x87, 0xff, 0x12, 0x04, 0x62, 0x2a, 0x4f, 0xb0, |
|
| 338 |
- 0x86, 0xf4, 0x02, 0x60, 0x81, 0x6f, 0x9a, 0x0b, |
|
| 339 |
- 0xa7, 0xf1, 0x06, 0x61, 0x9a, 0xb8, 0x72, 0x88 |
|
| 340 |
- ] |
|
| 341 |
- let cipher = try! AES(key: key, blockMode: ECB()) |
|
| 342 |
- let decryptedBuffer = Data( try cipher.decrypt(buffer.bytes) ) |
|
| 343 |
- let pac1: Data = decryptedBuffer.subdata( from: 0, length: 64 ) |
|
| 344 |
- if validatePac(id: 1, pac: pac1) {
|
|
| 345 |
- let pac2: Data = decryptedBuffer.subdata( from: 64, length: 64 ) |
|
| 346 |
- if validatePac(id: 2, pac: pac2) {
|
|
| 347 |
- let pac3: Data = decryptedBuffer.subdata( from: 128, length: 64 ) |
|
| 348 |
- if validatePac(id: 3, pac: pac3) {
|
|
| 349 |
- // let modelName = pac1.subdata(from: 4, length: 4).asciiString |
|
| 350 |
- // track("\(name) - Model: \(modelName)")
|
|
| 351 |
- // let firmwareVersion = pac1.subdata(from: 8, length: 4).asciiString |
|
| 352 |
- // track("\(name) - Firmware Version: \(firmwareVersion)")
|
|
| 353 |
- // let serialNumber = UInt32( littleEndian: pac1.value( from: 12 ) ) |
|
| 354 |
- // track("\(name) - Serial Number: \(serialNumber)")
|
|
| 355 |
- // let powerCycleCount = UInt32( littleEndian: pac1.value( from: 44 ) ) |
|
| 356 |
- // track("\(name) - Power Cycle Count: \(powerCycleCount)")
|
|
| 357 |
- voltage = Double( UInt32( littleEndian: pac1.value( from: 48) ) )/10000 |
|
| 358 |
- current = Double( UInt32( littleEndian: pac1.value( from: 52) ) )/100000 |
|
| 359 |
- power = Double( UInt32( littleEndian: pac1.value( from: 56) ) )/10000 |
|
| 360 |
- loadResistance = Double( UInt32( littleEndian: pac2.value( from: 4) ) )/10 |
|
| 361 |
- for index in stride(from: 0, through: 1, by: 1) {
|
|
| 362 |
- let offset = 8 + index * 8 |
|
| 363 |
- dataGroupRecords[index] = DataGroupRecord(ah: Double (UInt32( littleEndian: pac2.value( from: offset ) ) )/1000, wh:Double (UInt32( littleEndian: pac2.value( from: offset + 40 ) ) )/1000) |
|
| 364 |
- } |
|
| 365 |
- temperatureCelsius = Double( UInt32( littleEndian: pac2.value( from: 28 ) )) * ( UInt32( littleEndian: pac2.value( from: 24 ) ) == 1 ? -1 : 1 ) |
|
| 366 |
- usbPlusVoltage = Double( UInt32( littleEndian: pac2.value( from: 32) ) )/100 |
|
| 367 |
- usbMinusVoltage = Double( UInt32( littleEndian: pac2.value( from: 36) ) )/100 |
|
| 368 |
- return |
|
| 369 |
- } |
|
| 370 |
- } |
|
| 371 |
- } |
|
| 372 |
- track("\(name) - Invalid data")
|
|
| 373 | 351 |
|
| 374 |
- } catch {
|
|
| 375 |
- track("\(name) - Error: \(error)")
|
|
| 352 |
+ private func apply(tc66Snapshot snapshot: TC66Snapshot) {
|
|
| 353 |
+ voltage = snapshot.voltage |
|
| 354 |
+ current = snapshot.current |
|
| 355 |
+ power = snapshot.power |
|
| 356 |
+ loadResistance = snapshot.loadResistance |
|
| 357 |
+ for (index, record) in snapshot.dataGroupRecords {
|
|
| 358 |
+ dataGroupRecords[index] = DataGroupRecord(ah: record.ah, wh: record.wh) |
|
| 376 | 359 |
} |
| 360 |
+ temperatureCelsius = snapshot.temperatureCelsius |
|
| 361 |
+ usbPlusVoltage = snapshot.usbPlusVoltage |
|
| 362 |
+ usbMinusVoltage = snapshot.usbMinusVoltage |
|
| 377 | 363 |
} |
| 378 | 364 |
|
| 379 | 365 |
func nextScreen() {
|
| 380 | 366 |
switch model {
|
| 381 | 367 |
case .UM25C: |
| 382 |
- commandQueue.append( Data( [0xF1] ) ) |
|
| 368 |
+ commandQueue.append(UMProtocol.nextScreen) |
|
| 383 | 369 |
case .UM34C: |
| 384 |
- commandQueue.append( Data( [0xF1] ) ) |
|
| 370 |
+ commandQueue.append(UMProtocol.nextScreen) |
|
| 385 | 371 |
case .TC66C: |
| 386 |
- commandQueue.append( "bnextp\r\n".data(using: String.Encoding.ascii)! ) |
|
| 372 |
+ commandQueue.append(TC66Protocol.nextPage) |
|
| 387 | 373 |
} |
| 388 | 374 |
} |
| 389 | 375 |
|
| 390 | 376 |
func rotateScreen() {
|
| 391 | 377 |
switch model {
|
| 392 | 378 |
case .UM25C: |
| 393 |
- commandQueue.append( Data( [0xF2] ) ) |
|
| 379 |
+ commandQueue.append(UMProtocol.rotateScreen) |
|
| 394 | 380 |
case .UM34C: |
| 395 |
- commandQueue.append( Data( [0xF2] ) ) |
|
| 381 |
+ commandQueue.append(UMProtocol.rotateScreen) |
|
| 396 | 382 |
case .TC66C: |
| 397 |
- commandQueue.append( "brotat\r\n".data(using: String.Encoding.ascii)! ) |
|
| 383 |
+ commandQueue.append(TC66Protocol.rotateScreen) |
|
| 398 | 384 |
} |
| 399 | 385 |
} |
| 400 | 386 |
|
| 401 | 387 |
func previousScreen() {
|
| 402 | 388 |
switch model {
|
| 403 | 389 |
case .UM25C: |
| 404 |
- commandQueue.append( Data( [0xF3] ) ) |
|
| 390 |
+ commandQueue.append(UMProtocol.previousScreen) |
|
| 405 | 391 |
case .UM34C: |
| 406 |
- commandQueue.append( Data( [0xF3] ) ) |
|
| 392 |
+ commandQueue.append(UMProtocol.previousScreen) |
|
| 407 | 393 |
case .TC66C: |
| 408 |
- commandQueue.append( "blastp\r\n".data(using: String.Encoding.ascii)! ) |
|
| 394 |
+ commandQueue.append(TC66Protocol.previousPage) |
|
| 409 | 395 |
} |
| 410 | 396 |
} |
| 411 | 397 |
|
| 412 | 398 |
func clear() {
|
| 413 | 399 |
guard model != .TC66C else { return }
|
| 414 |
- commandQueue.append( Data( [0xF4] ) ) |
|
| 400 |
+ commandQueue.append(UMProtocol.clearCurrentGroup) |
|
| 415 | 401 |
} |
| 416 | 402 |
|
| 417 | 403 |
func clear(group id: UInt8) {
|
| 418 | 404 |
guard model != .TC66C else { return }
|
| 419 |
- commandQueue.append( Data( [0xA0 | id] ) ) |
|
| 405 |
+ commandQueue.append(UMProtocol.selectDataGroup(id)) |
|
| 420 | 406 |
clear() |
| 421 |
- commandQueue.append( Data( [0xA0 | selectedDataGroup] ) ) |
|
| 407 |
+ commandQueue.append(UMProtocol.selectDataGroup(selectedDataGroup)) |
|
| 422 | 408 |
} |
| 423 | 409 |
|
| 424 | 410 |
func selectDataGroup ( id: UInt8) {
|
| 411 |
+ guard supportsDataGroupCommands else { return }
|
|
| 425 | 412 |
track("\(name) - \(id)")
|
| 426 | 413 |
selectedDataGroup = id |
| 427 |
- commandQueue.append( Data( [0xA0 | selectedDataGroup] ) ) |
|
| 414 |
+ commandQueue.append(UMProtocol.selectDataGroup(selectedDataGroup)) |
|
| 428 | 415 |
} |
| 429 | 416 |
|
| 430 | 417 |
private func setSceeenBrightness ( to value: UInt8) {
|
| 431 | 418 |
track("\(name) - \(value)")
|
| 432 | 419 |
guard model != .TC66C else { return }
|
| 433 |
- commandQueue.append( Data( [0xD0 | value] ) ) |
|
| 420 |
+ commandQueue.append(UMProtocol.setScreenBrightness(value)) |
|
| 434 | 421 |
} |
| 435 | 422 |
private func setScreenSaverTimeout ( to value: UInt8) {
|
| 436 | 423 |
track("\(name) - \(value)")
|
| 437 | 424 |
guard model != .TC66C else { return }
|
| 438 |
- commandQueue.append( Data( [0xE0 | value]) ) |
|
| 425 |
+ commandQueue.append(UMProtocol.setScreenSaverTimeout(value)) |
|
| 439 | 426 |
} |
| 440 | 427 |
func setrecordingTreshold ( to value: UInt8) {
|
| 441 | 428 |
guard model != .TC66C else { return }
|
| 442 |
- commandQueue.append( Data( [0xB0 + value] ) ) |
|
| 429 |
+ commandQueue.append(UMProtocol.setRecordingThreshold(value)) |
|
| 443 | 430 |
} |
| 444 | 431 |
|
| 445 | 432 |
/** |
@@ -0,0 +1,108 @@ |
||
| 1 |
+// |
|
| 2 |
+// TC66Protocol.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Codex on 23/03/2026. |
|
| 6 |
+// |
|
| 7 |
+ |
|
| 8 |
+import Foundation |
|
| 9 |
+import CryptoSwift |
|
| 10 |
+ |
|
| 11 |
+struct TC66DataGroupTotals {
|
|
| 12 |
+ let ah: Double |
|
| 13 |
+ let wh: Double |
|
| 14 |
+} |
|
| 15 |
+ |
|
| 16 |
+struct TC66Snapshot {
|
|
| 17 |
+ let modelName: String |
|
| 18 |
+ let firmwareVersion: String |
|
| 19 |
+ let serialNumber: UInt32 |
|
| 20 |
+ let bootCount: UInt32 |
|
| 21 |
+ let voltage: Double |
|
| 22 |
+ let current: Double |
|
| 23 |
+ let power: Double |
|
| 24 |
+ let loadResistance: Double |
|
| 25 |
+ let dataGroupRecords: [Int: TC66DataGroupTotals] |
|
| 26 |
+ let temperatureCelsius: Double |
|
| 27 |
+ let usbPlusVoltage: Double |
|
| 28 |
+ let usbMinusVoltage: Double |
|
| 29 |
+} |
|
| 30 |
+ |
|
| 31 |
+enum TC66ProtocolError: Error {
|
|
| 32 |
+ case invalidPayloadLength(Int) |
|
| 33 |
+ case invalidPacket(Int) |
|
| 34 |
+} |
|
| 35 |
+ |
|
| 36 |
+enum TC66Protocol {
|
|
| 37 |
+ static let snapshotLength = 192 |
|
| 38 |
+ static let snapshotRequest = Data("bgetva\r\n".utf8)
|
|
| 39 |
+ static let nextPage = Data("bnextp\r\n".utf8)
|
|
| 40 |
+ static let previousPage = Data("blastp\r\n".utf8)
|
|
| 41 |
+ static let rotateScreen = Data("brotat\r\n".utf8)
|
|
| 42 |
+ |
|
| 43 |
+ private static let aesKey: [UInt8] = [ |
|
| 44 |
+ 0x58, 0x21, 0xfa, 0x56, 0x01, 0xb2, 0xf0, 0x26, |
|
| 45 |
+ 0x87, 0xff, 0x12, 0x04, 0x62, 0x2a, 0x4f, 0xb0, |
|
| 46 |
+ 0x86, 0xf4, 0x02, 0x60, 0x81, 0x6f, 0x9a, 0x0b, |
|
| 47 |
+ 0xa7, 0xf1, 0x06, 0x61, 0x9a, 0xb8, 0x72, 0x88 |
|
| 48 |
+ ] |
|
| 49 |
+ |
|
| 50 |
+ private static func validate(packetId: UInt8, packet: Data) -> Bool {
|
|
| 51 |
+ let expectedHeader = "pac\(packetId)".data(using: .ascii) |
|
| 52 |
+ let packetHeader = packet.subdata(from: 0, length: 4) |
|
| 53 |
+ let expectedCRC = UInt16(bigEndian: packet.subdata(from: 0, length: 60).crc16(seed: 0xFFFF).value(from: 0)) |
|
| 54 |
+ let packetCRC = UInt16(littleEndian: packet.value(from: 60)) |
|
| 55 |
+ return expectedHeader == packetHeader && expectedCRC == packetCRC |
|
| 56 |
+ } |
|
| 57 |
+ |
|
| 58 |
+ static func parseSnapshot(from buffer: Data) throws -> TC66Snapshot {
|
|
| 59 |
+ guard buffer.count == snapshotLength else {
|
|
| 60 |
+ throw TC66ProtocolError.invalidPayloadLength(buffer.count) |
|
| 61 |
+ } |
|
| 62 |
+ |
|
| 63 |
+ let cipher = try AES(key: aesKey, blockMode: ECB()) |
|
| 64 |
+ let decryptedBuffer = Data(try cipher.decrypt(buffer.bytes)) |
|
| 65 |
+ |
|
| 66 |
+ let pac1 = decryptedBuffer.subdata(from: 0, length: 64) |
|
| 67 |
+ guard validate(packetId: 1, packet: pac1) else {
|
|
| 68 |
+ throw TC66ProtocolError.invalidPacket(1) |
|
| 69 |
+ } |
|
| 70 |
+ |
|
| 71 |
+ let pac2 = decryptedBuffer.subdata(from: 64, length: 64) |
|
| 72 |
+ guard validate(packetId: 2, packet: pac2) else {
|
|
| 73 |
+ throw TC66ProtocolError.invalidPacket(2) |
|
| 74 |
+ } |
|
| 75 |
+ |
|
| 76 |
+ let pac3 = decryptedBuffer.subdata(from: 128, length: 64) |
|
| 77 |
+ guard validate(packetId: 3, packet: pac3) else {
|
|
| 78 |
+ throw TC66ProtocolError.invalidPacket(3) |
|
| 79 |
+ } |
|
| 80 |
+ |
|
| 81 |
+ var dataGroupRecords: [Int: TC66DataGroupTotals] = [:] |
|
| 82 |
+ for index in stride(from: 0, through: 1, by: 1) {
|
|
| 83 |
+ let offset = 8 + index * 8 |
|
| 84 |
+ dataGroupRecords[index] = TC66DataGroupTotals( |
|
| 85 |
+ ah: Double(UInt32(littleEndian: pac2.value(from: offset))) / 1000, |
|
| 86 |
+ wh: Double(UInt32(littleEndian: pac2.value(from: offset + 40))) / 1000 |
|
| 87 |
+ ) |
|
| 88 |
+ } |
|
| 89 |
+ |
|
| 90 |
+ let temperatureMagnitude = Double(UInt32(littleEndian: pac2.value(from: 28))) |
|
| 91 |
+ let temperatureSign = UInt32(littleEndian: pac2.value(from: 24)) == 1 ? -1.0 : 1.0 |
|
| 92 |
+ |
|
| 93 |
+ return TC66Snapshot( |
|
| 94 |
+ modelName: pac1.subdata(from: 4, length: 4).asciiString, |
|
| 95 |
+ firmwareVersion: pac1.subdata(from: 8, length: 4).asciiString, |
|
| 96 |
+ serialNumber: UInt32(littleEndian: pac1.value(from: 12)), |
|
| 97 |
+ bootCount: UInt32(littleEndian: pac1.value(from: 44)), |
|
| 98 |
+ voltage: Double(UInt32(littleEndian: pac1.value(from: 48))) / 10000, |
|
| 99 |
+ current: Double(UInt32(littleEndian: pac1.value(from: 52))) / 100000, |
|
| 100 |
+ power: Double(UInt32(littleEndian: pac1.value(from: 56))) / 10000, |
|
| 101 |
+ loadResistance: Double(UInt32(littleEndian: pac2.value(from: 4))) / 10, |
|
| 102 |
+ dataGroupRecords: dataGroupRecords, |
|
| 103 |
+ temperatureCelsius: temperatureMagnitude * temperatureSign, |
|
| 104 |
+ usbPlusVoltage: Double(UInt32(littleEndian: pac2.value(from: 32))) / 100, |
|
| 105 |
+ usbMinusVoltage: Double(UInt32(littleEndian: pac2.value(from: 36))) / 100 |
|
| 106 |
+ ) |
|
| 107 |
+ } |
|
| 108 |
+} |
|
@@ -0,0 +1,119 @@ |
||
| 1 |
+// |
|
| 2 |
+// UMProtocol.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Codex on 23/03/2026. |
|
| 6 |
+// |
|
| 7 |
+ |
|
| 8 |
+import Foundation |
|
| 9 |
+ |
|
| 10 |
+struct UMDataGroupTotals {
|
|
| 11 |
+ let ah: Double |
|
| 12 |
+ let wh: Double |
|
| 13 |
+} |
|
| 14 |
+ |
|
| 15 |
+struct UMSnapshot {
|
|
| 16 |
+ let modelNumber: UInt16 |
|
| 17 |
+ let voltage: Double |
|
| 18 |
+ let current: Double |
|
| 19 |
+ let power: Double |
|
| 20 |
+ let temperatureCelsius: Double |
|
| 21 |
+ let temperatureFahrenheit: Double |
|
| 22 |
+ let selectedDataGroup: UInt8 |
|
| 23 |
+ let dataGroupRecords: [Int: UMDataGroupTotals] |
|
| 24 |
+ let usbPlusVoltage: Double |
|
| 25 |
+ let usbMinusVoltage: Double |
|
| 26 |
+ let chargerTypeIndex: UInt16 |
|
| 27 |
+ let recordedAH: Double |
|
| 28 |
+ let recordedWH: Double |
|
| 29 |
+ let recordingThreshold: Double |
|
| 30 |
+ let recordingDuration: UInt32 |
|
| 31 |
+ let recording: Bool |
|
| 32 |
+ let screenTimeout: Int |
|
| 33 |
+ let screenBrightness: Int |
|
| 34 |
+ let loadResistance: Double |
|
| 35 |
+ let currentScreen: UInt16 |
|
| 36 |
+} |
|
| 37 |
+ |
|
| 38 |
+enum UMProtocolError: Error {
|
|
| 39 |
+ case invalidPayloadLength(Int) |
|
| 40 |
+} |
|
| 41 |
+ |
|
| 42 |
+enum UMProtocol {
|
|
| 43 |
+ static let minimumSnapshotLength = 128 |
|
| 44 |
+ static let snapshotRequest = Data([0xF0]) |
|
| 45 |
+ static let nextScreen = Data([0xF1]) |
|
| 46 |
+ static let rotateScreen = Data([0xF2]) |
|
| 47 |
+ static let previousScreen = Data([0xF3]) |
|
| 48 |
+ static let clearCurrentGroup = Data([0xF4]) |
|
| 49 |
+ |
|
| 50 |
+ static func selectDataGroup(_ id: UInt8) -> Data {
|
|
| 51 |
+ Data([0xA0 | id]) |
|
| 52 |
+ } |
|
| 53 |
+ |
|
| 54 |
+ static func setScreenBrightness(_ value: UInt8) -> Data {
|
|
| 55 |
+ Data([0xD0 | value]) |
|
| 56 |
+ } |
|
| 57 |
+ |
|
| 58 |
+ static func setScreenSaverTimeout(_ value: UInt8) -> Data {
|
|
| 59 |
+ Data([0xE0 | value]) |
|
| 60 |
+ } |
|
| 61 |
+ |
|
| 62 |
+ static func setRecordingThreshold(_ value: UInt8) -> Data {
|
|
| 63 |
+ Data([0xB0 + value]) |
|
| 64 |
+ } |
|
| 65 |
+ |
|
| 66 |
+ static func parseSnapshot(from buffer: Data, model: Model) throws -> UMSnapshot {
|
|
| 67 |
+ guard buffer.count >= minimumSnapshotLength else {
|
|
| 68 |
+ throw UMProtocolError.invalidPayloadLength(buffer.count) |
|
| 69 |
+ } |
|
| 70 |
+ |
|
| 71 |
+ let modelNumber = UInt16(bigEndian: buffer.value(from: 0)) |
|
| 72 |
+ |
|
| 73 |
+ let voltageScale: Double |
|
| 74 |
+ let currentScale: Double |
|
| 75 |
+ switch model {
|
|
| 76 |
+ case .UM25C: |
|
| 77 |
+ voltageScale = 1000 |
|
| 78 |
+ currentScale = 10000 |
|
| 79 |
+ case .UM34C: |
|
| 80 |
+ voltageScale = 100 |
|
| 81 |
+ currentScale = 1000 |
|
| 82 |
+ case .TC66C: |
|
| 83 |
+ voltageScale = 1 |
|
| 84 |
+ currentScale = 1 |
|
| 85 |
+ } |
|
| 86 |
+ |
|
| 87 |
+ var dataGroupRecords: [Int: UMDataGroupTotals] = [:] |
|
| 88 |
+ for index in stride(from: 0, through: 9, by: 1) {
|
|
| 89 |
+ let offset = 16 + index * 8 |
|
| 90 |
+ dataGroupRecords[index] = UMDataGroupTotals( |
|
| 91 |
+ ah: Double(UInt32(bigEndian: buffer.value(from: offset))) / 1000, |
|
| 92 |
+ wh: Double(UInt32(bigEndian: buffer.value(from: offset + 4))) / 1000 |
|
| 93 |
+ ) |
|
| 94 |
+ } |
|
| 95 |
+ |
|
| 96 |
+ return UMSnapshot( |
|
| 97 |
+ modelNumber: modelNumber, |
|
| 98 |
+ voltage: Double(UInt16(bigEndian: buffer.value(from: 2))) / voltageScale, |
|
| 99 |
+ current: Double(UInt16(bigEndian: buffer.value(from: 4))) / currentScale, |
|
| 100 |
+ power: Double(UInt32(bigEndian: buffer.value(from: 6))) / 1000, |
|
| 101 |
+ temperatureCelsius: Double(UInt16(bigEndian: buffer.value(from: 10))), |
|
| 102 |
+ temperatureFahrenheit: Double(UInt16(bigEndian: buffer.value(from: 12))), |
|
| 103 |
+ selectedDataGroup: UInt8(UInt16(bigEndian: buffer.value(from: 14))), |
|
| 104 |
+ dataGroupRecords: dataGroupRecords, |
|
| 105 |
+ usbPlusVoltage: Double(UInt16(bigEndian: buffer.value(from: 96))) / 100, |
|
| 106 |
+ usbMinusVoltage: Double(UInt16(bigEndian: buffer.value(from: 98))) / 100, |
|
| 107 |
+ chargerTypeIndex: UInt16(bigEndian: buffer.value(from: 100)), |
|
| 108 |
+ recordedAH: Double(UInt32(bigEndian: buffer.value(from: 102))) / 1000, |
|
| 109 |
+ recordedWH: Double(UInt32(bigEndian: buffer.value(from: 106))) / 1000, |
|
| 110 |
+ recordingThreshold: Double(UInt16(bigEndian: buffer.value(from: 110))) / 100, |
|
| 111 |
+ recordingDuration: UInt32(bigEndian: buffer.value(from: 112)), |
|
| 112 |
+ recording: UInt16(bigEndian: buffer.value(from: 116)) == 1, |
|
| 113 |
+ screenTimeout: Int(UInt16(bigEndian: buffer.value(from: 118))), |
|
| 114 |
+ screenBrightness: Int(UInt16(bigEndian: buffer.value(from: 120))), |
|
| 115 |
+ loadResistance: Double(UInt32(bigEndian: buffer.value(from: 122))) / 10, |
|
| 116 |
+ currentScreen: UInt16(bigEndian: buffer.value(from: 126)) |
|
| 117 |
+ ) |
|
| 118 |
+ } |
|
| 119 |
+} |
|
@@ -20,7 +20,8 @@ struct DataGroupRowView: View {
|
||
| 20 | 20 |
var body: some View {
|
| 21 | 21 |
HStack (spacing: 1) {
|
| 22 | 22 |
ZStack {
|
| 23 |
- Button(action: { self.usbMeter.selectDataGroup( id: self.id ) }, label: { Image(systemName: "\(id).circle") })//.font(.title)
|
|
| 23 |
+ Button(action: { self.usbMeter.selectDataGroup(id: self.id) }, label: { Image(systemName: "\(id).circle") })
|
|
| 24 |
+ .disabled(!usbMeter.supportsDataGroupCommands) |
|
| 24 | 25 |
Rectangle().opacity( opacity ) |
| 25 | 26 |
}.frame(width: width) |
| 26 | 27 |
|
@@ -36,8 +37,7 @@ struct DataGroupRowView: View {
|
||
| 36 | 37 |
|
| 37 | 38 |
ZStack {
|
| 38 | 39 |
Button(action: { self.usbMeter.clear(group: self.id) }, label: { Image(systemName: "bin.xmark") })
|
| 39 |
-// .font(.title) |
|
| 40 |
-// .foregroundColor(.red) |
|
| 40 |
+ .disabled(!usbMeter.supportsDataGroupCommands) |
|
| 41 | 41 |
Rectangle().opacity( opacity ) |
| 42 | 42 |
}.frame(width: width) |
| 43 | 43 |
} |
@@ -16,6 +16,8 @@ struct DataGroupsView: View {
|
||
| 16 | 16 |
|
| 17 | 17 |
var body: some View {
|
| 18 | 18 |
GeometryReader { box in
|
| 19 |
+ let rowCount = CGFloat(usbMeter.availableDataGroupIDs.count + 1) |
|
| 20 |
+ |
|
| 19 | 21 |
VStack (spacing: 1) {
|
| 20 | 22 |
HStack {
|
| 21 | 23 |
Text("Data Groups")
|
@@ -35,11 +37,11 @@ struct DataGroupsView: View {
|
||
| 35 | 37 |
self.THView( text: text, width: (box.size.width-25)/4 ) |
| 36 | 38 |
} |
| 37 | 39 |
} |
| 38 |
- .frame(height: ( box.size.height - 100 ) / 11) |
|
| 39 |
- ForEach((0...9), id: \.self) { groupId in
|
|
| 40 |
- DataGroupRowView( id: UInt8(groupId), width: ((box.size.width-25)/4 ), opacity : groupId.isMultiple(of: 2) ? 0.1 : 0.2 ) |
|
| 40 |
+ .frame(height: ( box.size.height - 100 ) / rowCount) |
|
| 41 |
+ ForEach(usbMeter.availableDataGroupIDs, id: \.self) { groupId in
|
|
| 42 |
+ DataGroupRowView(id: groupId, width: ((box.size.width-25) / 4), opacity: groupId.isMultiple(of: 2) ? 0.1 : 0.2) |
|
| 41 | 43 |
} |
| 42 |
- .frame(height: ( box.size.height - 100 ) / 11) |
|
| 44 |
+ .frame(height: ( box.size.height - 100 ) / rowCount) |
|
| 43 | 45 |
} |
| 44 | 46 |
.padding() |
| 45 | 47 |
} |
@@ -23,10 +23,14 @@ struct LiveView: View {
|
||
| 23 | 23 |
Text("Power:")
|
| 24 | 24 |
Text("Load")
|
| 25 | 25 |
Text("Temperature:")
|
| 26 |
- Text("")
|
|
| 26 |
+ if meter.supportsFahrenheit {
|
|
| 27 |
+ Text("")
|
|
| 28 |
+ } |
|
| 27 | 29 |
Text("USB Data+:")
|
| 28 | 30 |
Text("USB Data-:")
|
| 29 |
- Text("Charger:")
|
|
| 31 |
+ if meter.supportsChargerDetection {
|
|
| 32 |
+ Text("Charger:")
|
|
| 33 |
+ } |
|
| 30 | 34 |
} |
| 31 | 35 |
VStack (alignment: .trailing) {
|
| 32 | 36 |
HStack {
|
@@ -46,10 +50,14 @@ struct LiveView: View {
|
||
| 46 | 50 |
} |
| 47 | 51 |
Text("\(meter.loadResistance.format(decimalDigits: 1))Ω")
|
| 48 | 52 |
Text("\(meter.temperatureCelsius)℃")
|
| 49 |
- Text("\(meter.temperatureFahrenheit)℉")
|
|
| 53 |
+ if meter.supportsFahrenheit {
|
|
| 54 |
+ Text("\(meter.temperatureFahrenheit)℉")
|
|
| 55 |
+ } |
|
| 50 | 56 |
Text("\(meter.usbPlusVoltage.format(decimalDigits: 2))V")
|
| 51 | 57 |
Text("\(meter.usbMinusVoltage.format(decimalDigits: 2))V")
|
| 52 |
- Text("\(meter.chargerTypeIndex)")
|
|
| 58 |
+ if meter.supportsChargerDetection {
|
|
| 59 |
+ Text("\(meter.chargerTypeIndex)")
|
|
| 60 |
+ } |
|
| 53 | 61 |
} |
| 54 | 62 |
} |
| 55 | 63 |
.font(.footnote) |
@@ -35,7 +35,7 @@ struct MeterSettingsView: View {
|
||
| 35 | 35 |
} |
| 36 | 36 |
.padding() |
| 37 | 37 |
.background(RoundedRectangle(cornerRadius: 15).foregroundColor(.secondary).opacity(0.1)) |
| 38 |
- if meter.operationalState == .dataIsAvailable {
|
|
| 38 |
+ if meter.operationalState == .dataIsAvailable && meter.supportsUMSettings {
|
|
| 39 | 39 |
// MARK: Screen Timeout |
| 40 | 40 |
// Ar trebui separat enabled/disabled de valorile in minute eventual stocata valoarea in iCloud la dezactivare pentru restaurare |
| 41 | 41 |
VStack{
|
@@ -28,11 +28,15 @@ struct RecordingView: View {
|
||
| 28 | 28 |
Text("\(usbMeter.recordedAH.format(decimalDigits: 3)) Ah")
|
| 29 | 29 |
Text("\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh")
|
| 30 | 30 |
Text("\(usbMeter.recordingDuration) Sec")
|
| 31 |
- HStack {
|
|
| 32 |
- Slider(value: $usbMeter.recordingTreshold, in: 0...0.30, step: 0.01) |
|
| 33 |
- //.frame(width: 300) |
|
| 34 |
- Text("\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A")
|
|
| 35 |
- }.padding() |
|
| 31 |
+ if usbMeter.supportsRecordingThreshold {
|
|
| 32 |
+ HStack {
|
|
| 33 |
+ Slider(value: $usbMeter.recordingTreshold, in: 0...0.30, step: 0.01) |
|
| 34 |
+ Text("\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A")
|
|
| 35 |
+ } |
|
| 36 |
+ .padding() |
|
| 37 |
+ } else {
|
|
| 38 |
+ Text("N/A")
|
|
| 39 |
+ } |
|
| 36 | 40 |
}.padding() |
| 37 | 41 |
} |
| 38 | 42 |
} |