1 contributor
//
// File.swift
// USB Meter
//
// Created by Bogdan Timofte on 03/03/2020.
// Copyright © 2020 Bogdan Timofte. All rights reserved.
//
//MARK: Store and documentation: https://www.aliexpress.com/item/32968303350.html
//MARK: Protocol: https://sigrok.org/wiki/RDTech_UM_series
//MARK: Pithon Code: https://github.com/rfinnie/rdserialtool
//MARK: HM-10 Code: https://github.com/hoiberg/HM10-BluetoothSerial-iOS
//MARK: Package dependency https://github.com/krzyzanowskim/CryptoSwift
import CoreBluetooth
import CryptoSwift
import SwiftUI
/**
Supprted USB Meters
# UM25C
# TC66
* Reverse Engineering
[UM Series](https://sigrok.org/wiki/RDTech_UM_series)
[TC66C](https://sigrok.org/wiki/RDTech_TC66C)
*/
enum Model {
case UM25C
case UM34C
case TC66C
}
var modelRadios: [Model : BluetoothRadio] = [
.UM25C : .BT18,
.UM34C : .BT18,
.TC66C : .PW0316
]
var ModelByPeriferalName: [String : Model] = [
"UM25C" : .UM25C,
"UM34C" : .UM34C,
"TC66C" : .TC66C,
"PW0316" : .TC66C
]
var colorForModel: [Model : Color] = [
.UM25C : .blue,
.UM34C : .yellow,
.TC66C : .black
]
class Meter : NSObject, ObservableObject, Identifiable {
enum OperationalState: Int, Comparable {
case notPresent
case peripheralNotConnected
case peripheralConnectionPending
case peripheralConnected
case peripheralReady
case comunicating
case dataIsAvailable
static func < (lhs: OperationalState, rhs: OperationalState) -> Bool {
return lhs.rawValue < rhs.rawValue
}
}
@Published var operationalState = OperationalState.peripheralNotConnected {
didSet {
switch operationalState {
case .notPresent:
break
case .peripheralNotConnected:
if enableAutoConnect {
track("\(name) - Reconnecting...")
btSerial.connect()
}
case .peripheralConnectionPending:
break
case .peripheralConnected:
break
case .peripheralReady:
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { // Change `2.0` to the desired number of seconds.
self.dataDumpRequest()
}
case .comunicating:
break
case .dataIsAvailable:
break
}
}
}
static func operationalColor(for state: OperationalState) -> Color {
switch state {
case .notPresent:
return .red
case .peripheralNotConnected:
return .blue
case .peripheralConnectionPending:
return .yellow
case .peripheralConnected:
return .yellow
case .peripheralReady:
return .orange
case .comunicating:
return .orange
case .dataIsAvailable:
return .green
}
}
private var wdTimer: Timer?
@Published var lastSeen = Date() {
didSet {
wdTimer?.invalidate()
if operationalState == .peripheralNotConnected {
wdTimer = Timer.scheduledTimer(withTimeInterval: 20, repeats: false, block: {_ in
track("\(self.name) - Lost advertisments...")
self.operationalState = .notPresent
})
} else if operationalState == .notPresent {
operationalState = .peripheralNotConnected
}
}
}
var uuid: UUID
var model: Model
var modelString: String
var name: String {
didSet {
appData.meterNames[btSerial.macAddress.description] = name
}
}
var color : Color {
get {
return colorForModel[model]!
}
}
@Published var btSerial: BluetoothSerial
@Published var measurements = Measurements()
private var commandQueue: [Data] = []
private var dataDumpRequestTimestamp = Date()
class DataGroupRecord {
@Published var ah: Double
@Published var wh: Double
init(ah: Double, wh: Double) {
self.ah = ah
self.wh = wh
}
}
@Published var selectedDataGroup: UInt8 = 0
@Published var dataGroupRecords: [Int : DataGroupRecord] = [:]
@Published var screenBrightness: Int = -1 {
didSet {
if oldValue != screenBrightness {
screenBrightnessTimestamp = Date()
if oldValue != -1 {
setSceeenBrightness(to: UInt8(screenBrightness))
}
}
}
}
private var screenBrightnessTimestamp = Date()
@Published var screenTimeout: Int = -1 {
didSet {
if oldValue != screenTimeout {
screenTimeoutTimestamp = Date()
if oldValue != -1 {
setScreenSaverTimeout(to: UInt8(screenTimeout))
}
}
}
}
private var screenTimeoutTimestamp = Date()
@Published var voltage: Double = 0
@Published var current: Double = 0
@Published var power: Double = 0
@Published var temperatureCelsius: Double = 0
@Published var temperatureFahrenheit: Double = 0
@Published var usbPlusVoltage: Double = 0
@Published var usbMinusVoltage: Double = 0
@Published var recordedAH: Double = 0
@Published var recordedWH: Double = 0
@Published var recording: Bool = false
@Published var recordingTreshold: Double = 0 /* MARK: Seteaza inutil la pornire {
didSet {
if recordingTreshold != oldValue {
setrecordingTreshold(to: (recordingTreshold*100).uInt8Value)
}
}
} */
@Published var currentScreen: UInt16 = 0
@Published var recordingDuration: UInt32 = 0
@Published var loadResistance: Double = 0
@Published var modelNumber: UInt16 = 0
@Published var chargerTypeIndex: UInt16 = 0
private var enableAutoConnect: Bool = false
init ( model: Model, with serialPort: BluetoothSerial ) {
uuid = serialPort.peripheral.identifier
//dataStore.meterUUIDS.append(serialPort.peripheral.identifier)
modelString = serialPort.peripheral.name!
self.model = model
btSerial = serialPort
name = appData.meterNames[serialPort.macAddress.description] ?? serialPort.macAddress.description
super.init()
btSerial.delegate = self
//name = dataStore.meterNames[macAddress.description] ?? peripheral.name!
for index in stride(from: 0, through: 9, by: 1) {
dataGroupRecords[index] = DataGroupRecord(ah:0, wh: 0)
}
}
func dataDumpRequest() {
if commandQueue.isEmpty {
switch model {
case .UM25C:
btSerial.write( Data([0xF0]), expectedResponseLength: 130)
case .UM34C:
btSerial.write( Data([0xF0]), expectedResponseLength: 130)
case .TC66C:
btSerial.write( "bgetva\r\n".data(using: String.Encoding.ascii)!, expectedResponseLength: 192)
}
dataDumpRequestTimestamp = Date()
// track("\(name) - Request sent!")
} else {
track("Request delayed for: \(commandQueue.first!.hexEncodedStringValue)")
btSerial.write( commandQueue.first! )
commandQueue.removeFirst()
DispatchQueue.main.asyncAfter( deadline: .now() + 1 ) {
self.dataDumpRequest()
}
}
}
/**
received data parser
- parameter buffer cotains response for data dump request
- Decription metod for TC66C AES ECB response found [here](https:github.com/krzyzanowskim/CryptoSwift/issues/693)
*/
func parseData ( from buffer: Data) {
//track("\(name)")
switch model {
case .UM25C:
parseUMData(from: buffer)
case .UM34C:
parseUMData(from: buffer)
case .TC66C:
parseTCData( from: buffer )
}
measurements.addValues(timestamp: dataDumpRequestTimestamp, power: power, voltage: voltage, current: current)
// DispatchQueue.global(qos: .userInitiated).asyncAfter( deadline: .now() + 0.33 ) {
// //track("\(name) - Scheduled new request.")
// }
operationalState = .dataIsAvailable
dataDumpRequest()
}
func parseUMData(from buffer: Data) {
modelNumber = UInt16( bigEndian: buffer.value( from: 0 ) )
switch model {
case .UM25C:
voltage = Double( UInt16( bigEndian: buffer.value( from: 2) ) )/1000
current = Double( UInt16( bigEndian: buffer.value( from: 4) ) )/10000
case .UM34C:
voltage = Double( UInt16( bigEndian: buffer.value( from: 2) ) )/100
current = Double( UInt16( bigEndian: buffer.value( from: 4) ) )/1000
case .TC66C:
track("\(name) - This is not possible!")
}
power = Double( UInt32( bigEndian: buffer.value( from: 6) ) )/1000
temperatureCelsius = Double( UInt16( bigEndian: buffer.value( from: 10) ) )
temperatureFahrenheit = Double( UInt16( bigEndian: buffer.value( from: 12) ) )
selectedDataGroup = UInt8(UInt16( bigEndian: buffer.value( from: 14) ) )
for index in stride(from: 0, through: 9, by: 1) {
let offset = 16 + index * 8
dataGroupRecords[index] = DataGroupRecord(ah: Double (UInt32( bigEndian: buffer.value( from: offset ) ) )/1000, wh:Double (UInt32( bigEndian: buffer.value( from: offset + 4 ) ) )/1000)
}
usbPlusVoltage = Double( UInt16( bigEndian: buffer.value( from: 96) ) )/100
usbMinusVoltage = Double( UInt16( bigEndian: buffer.value( from: 98) ) )/100
chargerTypeIndex = UInt16( bigEndian: buffer.value( from: 100) )
recordedAH = Double (UInt32( bigEndian: buffer.value( from: 102 ) ) )/1000
recordedWH = Double (UInt32( bigEndian: buffer.value( from: 106 ) ) )/1000
recordingTreshold = Double (UInt16( bigEndian: buffer.value( from: 110 ) ) )/100
recordingDuration = UInt32( bigEndian: buffer.value( from: 112 ) )
recording = UInt16( bigEndian: buffer.value( from: 116 ) ) == 1
if screenTimeoutTimestamp < dataDumpRequestTimestamp {
let tmpValue = Int (UInt16( bigEndian: buffer.value( from: 118 ) ) )
if screenTimeout != tmpValue {
screenTimeout = tmpValue
}
} else {
track("\(name) - Skip updating screenTimeout (changed after request).")
}
if screenBrightnessTimestamp < dataDumpRequestTimestamp {
let tmpValue = Int (UInt16( bigEndian: buffer.value( from: 120 ) ) )
if screenBrightness != tmpValue {
screenBrightness = tmpValue
}
} else {
track("\(name) - Skip updating screenBrightness (changed after request).")
}
currentScreen = UInt16(bigEndian: buffer.value( from: 126 ) )
loadResistance = Double ( UInt32( bigEndian: buffer.value( from: 122 ) ) )/10
// track("\(name) - Model Number = \(modelNumber)")
// track("\(name) - chargerTypeIndex = \(chargerTypeIndex)")
}
private func validatePac ( id: UInt8, pac: Data ) -> Bool {
//track("\(name) - \(id) - \(pac.hexEncodedStringValue)")
let expectedHeader = "pac\(id)".data(using: String.Encoding.ascii)
let pacHeader = pac.subdata(from: 0, length: 4)
let expectedCRC = UInt16( bigEndian: pac.subdata(from: 0, length: 60).crc16(seed: 0xFFFF).value( from: 0 ) )
let pacCRC = UInt16( littleEndian: pac.value(from: 60) )
return expectedHeader == pacHeader && expectedCRC == pacCRC
}
func parseTCData(from buffer: Data) {
do {
let key: [UInt8] = [
0x58, 0x21, 0xfa, 0x56, 0x01, 0xb2, 0xf0, 0x26,
0x87, 0xff, 0x12, 0x04, 0x62, 0x2a, 0x4f, 0xb0,
0x86, 0xf4, 0x02, 0x60, 0x81, 0x6f, 0x9a, 0x0b,
0xa7, 0xf1, 0x06, 0x61, 0x9a, 0xb8, 0x72, 0x88
]
let cipher = try! AES(key: key, blockMode: ECB())
let decryptedBuffer = Data( try cipher.decrypt(buffer.bytes) )
let pac1: Data = decryptedBuffer.subdata( from: 0, length: 64 )
if validatePac(id: 1, pac: pac1) {
let pac2: Data = decryptedBuffer.subdata( from: 64, length: 64 )
if validatePac(id: 2, pac: pac2) {
let pac3: Data = decryptedBuffer.subdata( from: 128, length: 64 )
if validatePac(id: 3, pac: pac3) {
// let modelName = pac1.subdata(from: 4, length: 4).asciiString
// track("\(name) - Model: \(modelName)")
// let firmwareVersion = pac1.subdata(from: 8, length: 4).asciiString
// track("\(name) - Firmware Version: \(firmwareVersion)")
// let serialNumber = UInt32( littleEndian: pac1.value( from: 12 ) )
// track("\(name) - Serial Number: \(serialNumber)")
// let powerCycleCount = UInt32( littleEndian: pac1.value( from: 44 ) )
// track("\(name) - Power Cycle Count: \(powerCycleCount)")
voltage = Double( UInt32( littleEndian: pac1.value( from: 48) ) )/10000
current = Double( UInt32( littleEndian: pac1.value( from: 52) ) )/100000
power = Double( UInt32( littleEndian: pac1.value( from: 56) ) )/10000
loadResistance = Double( UInt32( littleEndian: pac2.value( from: 4) ) )/10
for index in stride(from: 0, through: 1, by: 1) {
let offset = 8 + index * 8
dataGroupRecords[index] = DataGroupRecord(ah: Double (UInt32( littleEndian: pac2.value( from: offset ) ) )/1000, wh:Double (UInt32( littleEndian: pac2.value( from: offset + 40 ) ) )/1000)
}
temperatureCelsius = Double( UInt32( littleEndian: pac2.value( from: 28 ) )) * ( UInt32( littleEndian: pac2.value( from: 24 ) ) == 1 ? -1 : 1 )
usbPlusVoltage = Double( UInt32( littleEndian: pac2.value( from: 32) ) )/100
usbMinusVoltage = Double( UInt32( littleEndian: pac2.value( from: 36) ) )/100
return
}
}
}
track("\(name) - Invalid data")
} catch {
track("\(name) - Error: \(error)")
}
}
func nextScreen() {
switch model {
case .UM25C:
commandQueue.append( Data( [0xF1] ) )
case .UM34C:
commandQueue.append( Data( [0xF1] ) )
case .TC66C:
commandQueue.append( "bnextp\r\n".data(using: String.Encoding.ascii)! )
}
}
func rotateScreen() {
switch model {
case .UM25C:
commandQueue.append( Data( [0xF2] ) )
case .UM34C:
commandQueue.append( Data( [0xF2] ) )
case .TC66C:
commandQueue.append( "brotat\r\n".data(using: String.Encoding.ascii)! )
}
}
func previousScreen() {
switch model {
case .UM25C:
commandQueue.append( Data( [0xF3] ) )
case .UM34C:
commandQueue.append( Data( [0xF3] ) )
case .TC66C:
commandQueue.append( "blastp\r\n".data(using: String.Encoding.ascii)! )
}
}
func clear() {
guard model != .TC66C else { return }
commandQueue.append( Data( [0xF4] ) )
}
func clear(group id: UInt8) {
guard model != .TC66C else { return }
commandQueue.append( Data( [0xA0 | id] ) )
clear()
commandQueue.append( Data( [0xA0 | selectedDataGroup] ) )
}
func selectDataGroup ( id: UInt8) {
track("\(name) - \(id)")
selectedDataGroup = id
commandQueue.append( Data( [0xA0 | selectedDataGroup] ) )
}
private func setSceeenBrightness ( to value: UInt8) {
track("\(name) - \(value)")
guard model != .TC66C else { return }
commandQueue.append( Data( [0xD0 | value] ) )
}
private func setScreenSaverTimeout ( to value: UInt8) {
track("\(name) - \(value)")
guard model != .TC66C else { return }
commandQueue.append( Data( [0xE0 | value]) )
}
func setrecordingTreshold ( to value: UInt8) {
guard model != .TC66C else { return }
commandQueue.append( Data( [0xB0 + value] ) )
}
/**
Connect to meter.
1. It calls BluetoothSerial.connect
*/
func connect() {
enableAutoConnect = true
btSerial.connect()
}
/**
Disconnect from meter.
It calls BluetoothSerial.disconnect
*/
func disconnect() {
enableAutoConnect = false
btSerial.disconnect()
}
}
extension Meter : SerialPortDelegate {
func opertionalStateChanged(to serialPortOperationalState: BluetoothSerial.OperationalState) {
lastSeen = Date()
//track("\(name) - \(serialPortOperationalState)")
switch serialPortOperationalState {
case .peripheralNotConnected:
operationalState = .peripheralNotConnected
case .peripheralConnectionPending:
operationalState = .peripheralConnectionPending
case .peripheralConnected:
operationalState = .peripheralConnected
case .peripheralReady:
operationalState = .peripheralReady
}
}
func didReceiveData(_ data: Data) {
lastSeen = Date()
operationalState = .comunicating
parseData(from: data)
}
}