Showing 9 changed files with 365 additions and 129 deletions
+8 -0
USB Meter.xcodeproj/project.pbxproj
@@ -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;
+99 -112
USB Meter/Model/Meter.swift
@@ -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
     /**
+108 -0
USB Meter/Model/TC66Protocol.swift
@@ -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
+}
+119 -0
USB Meter/Model/UMProtocol.swift
@@ -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
+}
+3 -3
USB Meter/Views/Meter/Data Groups/DataGroupRowView.swift
@@ -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
         }
+6 -4
USB Meter/Views/Meter/Data Groups/DataGroupsView.swift
@@ -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
         }
+12 -4
USB Meter/Views/Meter/LiveView.swift
@@ -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)
+1 -1
USB Meter/Views/Meter/MeterSettingsView.swift
@@ -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{
+9 -5
USB Meter/Views/Meter/RecordingView.swift
@@ -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
         }