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 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: CaseIterable {
case UM25C
case UM34C
case TC66C
}
enum TemperatureUnitPreference: String, CaseIterable, Identifiable {
case celsius
case fahrenheit
var id: String { rawValue }
var title: String {
switch self {
case .celsius:
return "Celsius"
case .fahrenheit:
return "Fahrenheit"
}
}
var symbol: String {
switch self {
case .celsius:
return "℃"
case .fahrenheit:
return "℉"
}
}
}
private extension TemperatureUnitPreference {
var localeTitle: String {
switch self {
case .celsius:
return "System (Celsius)"
case .fahrenheit:
return "System (Fahrenheit)"
}
}
}
enum ChargeRecordState {
case waitingForStart
case active
case completed
}
class Meter : NSObject, ObservableObject, Identifiable {
private static func shouldLogOperationalStateTransition(from oldValue: OperationalState, to newValue: OperationalState) -> Bool {
switch (oldValue, newValue) {
case (.comunicating, .dataIsAvailable), (.dataIsAvailable, .comunicating):
return false
default:
return true
}
}
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 {
guard operationalState != oldValue else { return }
if Self.shouldLogOperationalStateTransition(from: oldValue, to: operationalState) {
track("\(name) - Operational state changed from \(oldValue) to \(operationalState)")
}
switch operationalState {
case .notPresent:
cancelPendingDataDumpRequest(reason: "meter missing")
break
case .peripheralNotConnected:
cancelPendingDataDumpRequest(reason: "peripheral disconnected")
if !commandQueue.isEmpty {
track("\(name) - Clearing \(commandQueue.count) queued commands after disconnect")
commandQueue.removeAll()
}
if enableAutoConnect {
track("\(name) - Reconnecting...")
btSerial.connect()
}
case .peripheralConnectionPending:
cancelPendingDataDumpRequest(reason: "connection pending")
break
case .peripheralConnected:
cancelPendingDataDumpRequest(reason: "services not ready yet")
break
case .peripheralReady:
scheduleDataDumpRequest(after: 0.5, reason: "peripheral ready")
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 model.color
}
}
var capabilities: MeterCapabilities {
model.capabilities
}
var availableDataGroupIDs: [UInt8] {
capabilities.availableDataGroupIDs
}
var supportsDataGroupCommands: Bool {
capabilities.supportsDataGroupCommands
}
var supportsRecordingView: Bool {
capabilities.supportsRecordingView
}
var supportsUMSettings: Bool {
capabilities.supportsScreenSettings
}
var supportsRecordingThreshold: Bool {
capabilities.supportsRecordingThreshold
}
var reportsCurrentScreenIndex: Bool {
capabilities.reportsCurrentScreenIndex
}
var showsDataGroupEnergy: Bool {
capabilities.showsDataGroupEnergy
}
var highlightsActiveDataGroup: Bool {
if model == .TC66C {
return hasObservedActiveDataGroup
}
return capabilities.highlightsActiveDataGroup
}
var supportsFahrenheit: Bool {
capabilities.supportsFahrenheit
}
var supportsManualTemperatureUnitSelection: Bool {
model == .TC66C
}
var supportsChargerDetection: Bool {
capabilities.supportsChargerDetection
}
var dataGroupsTitle: String {
capabilities.dataGroupsTitle
}
var documentedWorkingVoltage: String {
capabilities.documentedWorkingVoltage
}
var chargerTypeDescription: String {
capabilities.chargerTypeDescription(for: chargerTypeIndex)
}
var temperatureUnitDescription: String {
if supportsManualTemperatureUnitSelection {
return "Device-defined"
}
return systemTemperatureUnitPreference.localeTitle
}
var primaryTemperatureDescription: String {
let value = displayedTemperatureValue.format(decimalDigits: 0)
if supportsManualTemperatureUnitSelection {
return "\(value)°"
}
return "\(value)\(systemTemperatureUnitPreference.symbol)"
}
var secondaryTemperatureDescription: String? {
nil
}
var displayedTemperatureValue: Double {
if supportsManualTemperatureUnitSelection {
return temperatureCelsius
}
switch systemTemperatureUnitPreference {
case .celsius:
return displayedTemperatureCelsius
case .fahrenheit:
return displayedTemperatureFahrenheit
}
}
private var displayedTemperatureCelsius: Double {
if supportsManualTemperatureUnitSelection {
switch tc66TemperatureUnitPreference {
case .celsius:
return temperatureCelsius
case .fahrenheit:
return (temperatureCelsius - 32) * 5 / 9
}
}
return temperatureCelsius
}
private var displayedTemperatureFahrenheit: Double {
if supportsManualTemperatureUnitSelection {
switch tc66TemperatureUnitPreference {
case .celsius:
return (temperatureCelsius * 9 / 5) + 32
case .fahrenheit:
return temperatureCelsius
}
}
if supportsFahrenheit, temperatureFahrenheit.isFinite {
return temperatureFahrenheit
}
return (temperatureCelsius * 9 / 5) + 32
}
private var systemTemperatureUnitPreference: TemperatureUnitPreference {
let locale = Locale.autoupdatingCurrent
if #available(iOS 16.0, *) {
switch locale.measurementSystem {
case .us:
return .fahrenheit
default:
return .celsius
}
}
let regionCode = locale.regionCode ?? ""
let fahrenheitRegions: Set<String> = ["US", "BS", "BZ", "KY", "PW", "LR", "FM", "MH"]
return fahrenheitRegions.contains(regionCode) ? .fahrenheit : .celsius
}
var currentScreenDescription: String {
guard reportsCurrentScreenIndex else {
return "Page Controls"
}
if let label = capabilities.screenDescription(for: currentScreen) {
return "Screen \(currentScreen): \(label)"
}
return "Screen \(currentScreen)"
}
var deviceModelName: String {
if !reportedModelName.isEmpty {
return reportedModelName
}
return model.canonicalName
}
var deviceModelSummary: String {
let baseName = deviceModelName
if modelNumber != 0 {
return "\(baseName) (\(modelNumber))"
}
return baseName
}
var recordingDurationDescription: String {
let totalSeconds = Int(recordingDuration)
let hours = totalSeconds / 3600
let minutes = (totalSeconds % 3600) / 60
let seconds = totalSeconds % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
}
return String(format: "%02d:%02d", minutes, seconds)
}
var chargeRecordDurationDescription: String {
let totalSeconds = Int(chargeRecordDuration)
let hours = totalSeconds / 3600
let minutes = (totalSeconds % 3600) / 60
let seconds = totalSeconds % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
}
return String(format: "%02d:%02d", minutes, seconds)
}
var chargeRecordTimeRange: ClosedRange<Date>? {
guard let start = chargeRecordStartTimestamp else { return nil }
let end = chargeRecordEndTimestamp ?? chargeRecordLastTimestamp
guard let end else { return nil }
return start...end
}
var chargeRecordStatusText: String {
switch chargeRecordState {
case .waitingForStart:
return "Waiting"
case .active:
return "Active"
case .completed:
return "Completed"
}
}
var chargeRecordStatusColor: Color {
switch chargeRecordState {
case .waitingForStart:
return .secondary
case .active:
return .red
case .completed:
return .green
}
}
var dataGroupsHint: String? {
if model == .TC66C {
if hasObservedActiveDataGroup {
return "The active memory is inferred from the totals that are currently increasing."
}
return "The device exposes two read-only memories. The active memory is inferred once one total starts increasing."
}
return capabilities.dataGroupsHint
}
func dataGroupLabel(for id: UInt8) -> String {
supportsDataGroupCommands ? "\(id)" : "M\(Int(id) + 1)"
}
var recordingThresholdHint: String? {
capabilities.recordingThresholdHint
}
@Published var btSerial: BluetoothSerial
@Published var measurements = Measurements()
private var commandQueue: [Data] = []
private var dataDumpRequestTimestamp = Date()
private var pendingDataDumpWorkItem: DispatchWorkItem?
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 chargeRecordAH: Double = 0
@Published var chargeRecordWH: Double = 0
@Published var chargeRecordDuration: TimeInterval = 0
@Published var chargeRecordStopThreshold: Double = 0.05
@Published var tc66TemperatureUnitPreference: TemperatureUnitPreference = .celsius {
didSet {
guard supportsManualTemperatureUnitSelection else { return }
guard oldValue != tc66TemperatureUnitPreference else { return }
var settings = appData.tc66TemperatureUnits
settings[btSerial.macAddress.description] = tc66TemperatureUnitPreference.rawValue
appData.tc66TemperatureUnits = settings
}
}
@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 {
didSet {
guard recordingTreshold != oldValue else { return }
if isApplyingRecordingThresholdFromDevice {
return
}
recordingThresholdTimestamp = Date()
guard recordingThresholdLoadedFromDevice else { return }
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
@Published var reportedModelName: String = ""
@Published var firmwareVersion: String = ""
@Published var serialNumber: UInt32 = 0
@Published var bootCount: UInt32 = 0
private var enableAutoConnect: Bool = false
private var recordingThresholdTimestamp = Date()
private var recordingThresholdLoadedFromDevice = false
private var isApplyingRecordingThresholdFromDevice = false
@Published private(set) var chargeRecordState = ChargeRecordState.waitingForStart
private var chargeRecordStartTimestamp: Date?
private var chargeRecordEndTimestamp: Date?
private var chargeRecordLastTimestamp: Date?
private var chargeRecordLastCurrent: Double = 0
private var chargeRecordLastPower: Double = 0
private let volatileMemoryDecreaseEpsilon = 0.0005
private let initiatedVolatileMemoryResetGraceWindow: TimeInterval = 12
private var hasSeenUMSnapshot = false
private var hasObservedActiveDataGroup = false
private var hasSeenTC66Snapshot = false
private var pendingVolatileMemoryResetIgnoreCount = 0
private var pendingVolatileMemoryResetDeadline: Date?
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
reloadTemperatureUnitPreference()
//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 reloadTemperatureUnitPreference() {
guard supportsManualTemperatureUnitSelection else { return }
let rawValue = appData.tc66TemperatureUnits[btSerial.macAddress.description] ?? TemperatureUnitPreference.celsius.rawValue
let persistedPreference = TemperatureUnitPreference(rawValue: rawValue) ?? .celsius
if tc66TemperatureUnitPreference != persistedPreference {
tc66TemperatureUnitPreference = persistedPreference
}
}
private func cancelPendingDataDumpRequest(reason: String) {
guard let pendingDataDumpWorkItem else { return }
track("\(name) - Cancel scheduled data request (\(reason))")
pendingDataDumpWorkItem.cancel()
self.pendingDataDumpWorkItem = nil
}
private func scheduleDataDumpRequest(after delay: TimeInterval, reason: String) {
cancelPendingDataDumpRequest(reason: "reschedule")
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
self.pendingDataDumpWorkItem = nil
self.dataDumpRequest()
}
pendingDataDumpWorkItem = workItem
track("\(name) - Schedule data request in \(String(format: "%.2f", delay))s (\(reason))")
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
}
private func noteInitiatedVolatileMemoryResetIfNeeded(for groupID: UInt8) {
guard groupID == 0 else { return }
pendingVolatileMemoryResetIgnoreCount += 1
pendingVolatileMemoryResetDeadline = Date().addingTimeInterval(initiatedVolatileMemoryResetGraceWindow)
track("\(name) - Will ignore the next volatile memory drop caused by an app reset request.")
}
private func pendingInitiatedVolatileMemoryResetIsActive(at timestamp: Date) -> Bool {
guard let pendingVolatileMemoryResetDeadline else { return false }
guard pendingVolatileMemoryResetIgnoreCount > 0 else {
self.pendingVolatileMemoryResetDeadline = nil
return false
}
guard timestamp <= pendingVolatileMemoryResetDeadline else {
track("\(name) - Expiring stale volatile memory reset ignore state.")
pendingVolatileMemoryResetIgnoreCount = 0
self.pendingVolatileMemoryResetDeadline = nil
return false
}
return true
}
private func shouldIgnoreVolatileMemoryDrop(at timestamp: Date) -> Bool {
guard pendingInitiatedVolatileMemoryResetIsActive(at: timestamp) else { return false }
pendingVolatileMemoryResetIgnoreCount -= 1
if pendingVolatileMemoryResetIgnoreCount == 0 {
pendingVolatileMemoryResetDeadline = nil
}
track("\(name) - Ignoring volatile memory drop after an app-initiated reset.")
return true
}
private func didUMVolatileMemoryDecrease(in snapshot: UMSnapshot) -> Bool {
guard hasSeenUMSnapshot else { return false }
guard let previousRecord = dataGroupRecords[0], let nextRecord = snapshot.dataGroupRecords[0] else {
return false
}
return nextRecord.ah < (previousRecord.ah - volatileMemoryDecreaseEpsilon)
|| nextRecord.wh < (previousRecord.wh - volatileMemoryDecreaseEpsilon)
}
private func didUMDeviceReboot(with snapshot: UMSnapshot, at timestamp: Date) -> Bool {
defer { hasSeenUMSnapshot = true }
guard didUMVolatileMemoryDecrease(in: snapshot) else { return false }
guard !shouldIgnoreVolatileMemoryDrop(at: timestamp) else { return false }
track("\(name) - Inferred UM reboot because volatile memory dropped.")
return true
}
private func didTC66DeviceReboot(with snapshot: TC66Snapshot) -> Bool {
guard hasSeenTC66Snapshot else { return false }
guard snapshot.bootCount != bootCount else { return false }
track("\(name) - Inferred TC66 reboot because bootCount changed from \(bootCount) to \(snapshot.bootCount).")
return true
}
private func updateChargerTypeLatch(observedIndex: UInt16, didDetectDeviceReset: Bool) {
if didDetectDeviceReset, chargerTypeIndex != 0 {
chargerTypeIndex = 0
}
guard supportsChargerDetection else { return }
if chargerTypeIndex == 0 {
chargerTypeIndex = observedIndex
return
}
guard observedIndex != 0, observedIndex != chargerTypeIndex else { return }
track("\(name) - Ignoring charger type change from \(chargerTypeIndex) to \(observedIndex) until the device reboots.")
}
func dataDumpRequest() {
guard operationalState >= .peripheralReady else {
track("\(name) - Skip data request while state is \(operationalState)")
return
}
if commandQueue.isEmpty {
switch model {
case .UM25C:
btSerial.write(UMProtocol.snapshotRequest, expectedResponseLength: 130)
case .UM34C:
btSerial.write(UMProtocol.snapshotRequest, expectedResponseLength: 130)
case .TC66C:
btSerial.write(TC66Protocol.snapshotRequest, expectedResponseLength: 192)
}
dataDumpRequestTimestamp = Date()
// track("\(name) - Request sent!")
} else {
track("Request delayed for: \(commandQueue.first!.hexEncodedStringValue)")
btSerial.write( commandQueue.first! )
commandQueue.removeFirst()
scheduleDataDumpRequest(after: 1, reason: "queued command")
}
}
/**
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:
do {
apply(umSnapshot: try UMProtocol.parseSnapshot(from: buffer, model: model))
} catch {
track("\(name) - Error: \(error)")
}
case .UM34C:
do {
apply(umSnapshot: try UMProtocol.parseSnapshot(from: buffer, model: model))
} catch {
track("\(name) - Error: \(error)")
}
case .TC66C:
do {
apply(tc66Snapshot: try TC66Protocol.parseSnapshot(from: buffer))
} catch {
track("\(name) - Error: \(error)")
}
}
updateChargeRecord(at: dataDumpRequestTimestamp)
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()
}
private func apply(umSnapshot snapshot: UMSnapshot) {
let didDetectDeviceReset = didUMDeviceReboot(with: snapshot, at: dataDumpRequestTimestamp)
modelNumber = snapshot.modelNumber
voltage = snapshot.voltage
current = snapshot.current
power = snapshot.power
temperatureCelsius = snapshot.temperatureCelsius
temperatureFahrenheit = snapshot.temperatureFahrenheit
selectedDataGroup = snapshot.selectedDataGroup
for (index, record) in snapshot.dataGroupRecords {
dataGroupRecords[index] = DataGroupRecord(ah: record.ah, wh: record.wh)
}
usbPlusVoltage = snapshot.usbPlusVoltage
usbMinusVoltage = snapshot.usbMinusVoltage
updateChargerTypeLatch(observedIndex: snapshot.chargerTypeIndex, didDetectDeviceReset: didDetectDeviceReset)
recordedAH = snapshot.recordedAH
recordedWH = snapshot.recordedWH
if recordingThresholdTimestamp < dataDumpRequestTimestamp || !recordingThresholdLoadedFromDevice {
recordingThresholdLoadedFromDevice = true
if recordingTreshold != snapshot.recordingThreshold {
isApplyingRecordingThresholdFromDevice = true
recordingTreshold = snapshot.recordingThreshold
isApplyingRecordingThresholdFromDevice = false
}
} else {
track("\(name) - Skip updating recordingThreshold (changed after request).")
}
recordingDuration = snapshot.recordingDuration
recording = snapshot.recording
if screenTimeoutTimestamp < dataDumpRequestTimestamp {
if screenTimeout != snapshot.screenTimeout {
screenTimeout = snapshot.screenTimeout
}
} else {
track("\(name) - Skip updating screenTimeout (changed after request).")
}
if screenBrightnessTimestamp < dataDumpRequestTimestamp {
if screenBrightness != snapshot.screenBrightness {
screenBrightness = snapshot.screenBrightness
}
} else {
track("\(name) - Skip updating screenBrightness (changed after request).")
}
currentScreen = snapshot.currentScreen
loadResistance = snapshot.loadResistance
}
private func apply(tc66Snapshot snapshot: TC66Snapshot) {
let didDetectDeviceReset = didTC66DeviceReboot(with: snapshot)
if hasSeenTC66Snapshot {
inferTC66ActiveDataGroup(from: snapshot)
} else {
hasSeenTC66Snapshot = true
}
reportedModelName = snapshot.modelName
firmwareVersion = snapshot.firmwareVersion
serialNumber = snapshot.serialNumber
bootCount = snapshot.bootCount
updateChargerTypeLatch(observedIndex: 0, didDetectDeviceReset: didDetectDeviceReset)
voltage = snapshot.voltage
current = snapshot.current
power = snapshot.power
loadResistance = snapshot.loadResistance
for (index, record) in snapshot.dataGroupRecords {
dataGroupRecords[index] = DataGroupRecord(ah: record.ah, wh: record.wh)
}
temperatureCelsius = snapshot.temperatureCelsius
usbPlusVoltage = snapshot.usbPlusVoltage
usbMinusVoltage = snapshot.usbMinusVoltage
}
private func inferTC66ActiveDataGroup(from snapshot: TC66Snapshot) {
let candidate = snapshot.dataGroupRecords.compactMap { entry -> (UInt8, Double)? in
let index = entry.key
let record = entry.value
guard let previous = dataGroupRecords[index] else { return nil }
let deltaAH = max(record.ah - previous.ah, 0)
let deltaWH = max(record.wh - previous.wh, 0)
let score = deltaAH + deltaWH
guard score > 0 else { return nil }
return (UInt8(index), score)
}
.max { lhs, rhs in lhs.1 < rhs.1 }
if let candidate {
selectedDataGroup = candidate.0
hasObservedActiveDataGroup = true
}
}
private func updateChargeRecord(at timestamp: Date) {
switch chargeRecordState {
case .waitingForStart:
guard current > chargeRecordStopThreshold else { return }
chargeRecordState = .active
chargeRecordStartTimestamp = timestamp
chargeRecordEndTimestamp = timestamp
chargeRecordLastTimestamp = timestamp
chargeRecordLastCurrent = current
chargeRecordLastPower = power
case .active:
if let lastTimestamp = chargeRecordLastTimestamp {
let deltaSeconds = max(timestamp.timeIntervalSince(lastTimestamp), 0)
chargeRecordAH += chargeRecordLastCurrent * deltaSeconds / 3600
chargeRecordWH += chargeRecordLastPower * deltaSeconds / 3600
chargeRecordDuration += deltaSeconds
}
chargeRecordEndTimestamp = timestamp
chargeRecordLastTimestamp = timestamp
chargeRecordLastCurrent = current
chargeRecordLastPower = power
if current <= chargeRecordStopThreshold {
chargeRecordState = .completed
}
case .completed:
break
}
}
func resetChargeRecord() {
chargeRecordAH = 0
chargeRecordWH = 0
chargeRecordDuration = 0
chargeRecordState = .waitingForStart
chargeRecordStartTimestamp = nil
chargeRecordEndTimestamp = nil
chargeRecordLastTimestamp = nil
chargeRecordLastCurrent = 0
chargeRecordLastPower = 0
}
func resetChargeRecordGraph() {
let cutoff = Date()
resetChargeRecord()
measurements.trim(before: cutoff)
}
func nextScreen() {
switch model {
case .UM25C:
commandQueue.append(UMProtocol.nextScreen)
case .UM34C:
commandQueue.append(UMProtocol.nextScreen)
case .TC66C:
commandQueue.append(TC66Protocol.nextPage)
}
}
func rotateScreen() {
switch model {
case .UM25C:
commandQueue.append(UMProtocol.rotateScreen)
case .UM34C:
commandQueue.append(UMProtocol.rotateScreen)
case .TC66C:
commandQueue.append(TC66Protocol.rotateScreen)
}
}
func previousScreen() {
switch model {
case .UM25C:
commandQueue.append(UMProtocol.previousScreen)
case .UM34C:
commandQueue.append(UMProtocol.previousScreen)
case .TC66C:
commandQueue.append(TC66Protocol.previousPage)
}
}
func clear() {
guard supportsDataGroupCommands else { return }
noteInitiatedVolatileMemoryResetIfNeeded(for: selectedDataGroup)
commandQueue.append(UMProtocol.clearCurrentGroup)
}
func clear(group id: UInt8) {
guard supportsDataGroupCommands else { return }
commandQueue.append(UMProtocol.selectDataGroup(id))
noteInitiatedVolatileMemoryResetIfNeeded(for: id)
commandQueue.append(UMProtocol.clearCurrentGroup)
commandQueue.append(UMProtocol.selectDataGroup(selectedDataGroup))
}
func selectDataGroup ( id: UInt8) {
guard supportsDataGroupCommands else { return }
track("\(name) - \(id)")
selectedDataGroup = id
commandQueue.append(UMProtocol.selectDataGroup(selectedDataGroup))
}
private func setSceeenBrightness ( to value: UInt8) {
track("\(name) - \(value)")
guard supportsUMSettings else { return }
commandQueue.append(UMProtocol.setScreenBrightness(value))
}
private func setScreenSaverTimeout ( to value: UInt8) {
track("\(name) - \(value)")
guard supportsUMSettings else { return }
commandQueue.append(UMProtocol.setScreenSaverTimeout(value))
}
func setrecordingTreshold ( to value: UInt8) {
guard supportsRecordingThreshold else { return }
commandQueue.append(UMProtocol.setRecordingThreshold(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) {
let applyStateChange = {
self.lastSeen = Date()
switch serialPortOperationalState {
case .peripheralNotConnected:
self.operationalState = .peripheralNotConnected
case .peripheralConnectionPending:
self.operationalState = .peripheralConnectionPending
case .peripheralConnected:
self.operationalState = .peripheralConnected
case .peripheralReady:
self.operationalState = .peripheralReady
}
}
if Thread.isMainThread {
applyStateChange()
} else {
DispatchQueue.main.async(execute: applyStateChange)
}
}
func didReceiveData(_ data: Data) {
let applyData = {
self.lastSeen = Date()
self.operationalState = .comunicating
self.parseData(from: data)
}
if Thread.isMainThread {
applyData()
} else {
DispatchQueue.main.async(execute: applyData)
}
}
}