О Core Bluetooth замолвите слово… или что делать с загадочным девайсом, когда задач много, а девелопер один

—

от автора

Начну с себя. Я и есть тот самый iOS девелопер, работающий в компании Orion Innovation, которому посчастливилось разбирать функционал и придумать универсальный инструмент, применимый в разных кейсах. И у меня есть вопросы:

  • Как часто вам приходится работать с реальными устройствами в мире мобильных девайсов?

  • А что, если ваше приложение отличается от типичных клиент-серверных?

На всё про всё – массив байт. В нем заложены команды, и они отправляются на блютуз девайс. Как же нам конвертировать это в неклассическую модель данных, совсем непохожую на привычный Json? Интересно? Мои идеи и работающие решения в этой статье.

А если серьезно, то продолжительное время мой проект занимается разработкой приложения для управления чипом по протоколу BLE. Отдельная группа инженеров работает непосредственно с железом, занимаясь тем, что готовит API для нас. Сам чип управляет различными девайсами в зависимости от того, с какой платой его сконнектить. Инженеры создали свой функционал, а мы…  на стороне клиента должны работать четко по предоставленной ими инструкции.

Я нарисовал схему, которая может помочь понять, как же все устроено:

Казалось бы все просто – есть сервис, у него есть характеристики, берешь, считываешь данные в конкретной характеристике, меняешь/записываешь новое свойство.

Но!

А что если у тебя есть интересный девайс, для которого умные инженеры написали универсальный протокол, позволяющий решить потрясающую задачу– избавить Core Bluetooth от потребности «помнить» массу данных (сервисов) и использовать всего лишь один, читая и записывая ВСЕ данные в одну характеристику. При этом учитывая, что форматы передачи данных могут отличаться, да и сами сообщения тоже. Получаем настоящий швейцарский нож!

Функционал этого чипа настолько велик, что по большому счету не важно, с какими именно девайсом его интегрировать. Важно только, КАК именно он работает.

Инженеры, безусловно, крайне умные и решили свою задачу с элегантностью пантеры. Но что же делать девелоперу?

Я имею более 20 типов сообщений, представляющих собой байтовые массивы. Они легковесны и универсальны – достаточно одной характеристики. Все просто.
Но вот задача – на стороне iOS нужен механизм. Желательно, столь же простой, удобный и универсальный.

Напомню – тип девайса, на котором установлен конкретный чип, не имеет значения. Приложение должно умело и четко коммуницировать, используя Bluetooth.

Что имеем: инженеры написали «протокол», единый для всех типов девайсов. Реализован на стороне девайса. Несет в себе более чем 20 видов сообщений.

Вывод: необходимо покаверить их все. Задача не из простых.

Что-то придумали инженеры, настала очередь iOS.

Стандартный формат пакета
Стандартный формат пакета

Для начала все запросы нужно классифицировать, используя предоставленную документацию. 

Задача реализована следующим способом: 

protocol PeripheralPacketProtocol {     var header: PeripheralHeader { get set }     var payload: Payload { get set }     var crcLow: Byte { get set }     var crcHight: Byte { get set }     func toData() -> [Byte] }  struct PeripheralPacket: PeripheralPacketProtocol {     var header: PeripheralHeader     var payload: Payload     var crcLow: Byte     var crcHight: Byte     init(header: PeripheralHeader, payload: Payload) {         self.header = header         self.payload = payload         var packet = header.toData()         packet.append(contentsOf: payload.toData())         let data = Data(packet)         let crc = CRCCalculator.calculateCRCUInt8(data: data)         self.crcLow = crc.crcLow         self.crcHight = crc.crcHight     }     func toData() -> [Byte] {         var packet = header.toData()         packet.append(contentsOf: payload.toData())         packet.append(crcLow)         packet.append(crcHight)         return packet     } } 

У Вас может возникнуть вопрос – что же такое toData? Это – непосредственно наш массив байт, который в конечном итоге отправится на девайс. В коде это выглядит, как  typealias к UINT8.

typealias Byte = UInt8 struct Uuid {     var value: [Byte] = [] }

Можно заметить, что выделена общая часть запроса. Она будет статичной всегда. 

Мы назвали ее header (неизменяемая часть сообщения) 

protocol PeripheralHeaderProtocol {     var destinationAdress: Address { get set }     var destinationBus: Byte { get set }     var sourceAddress: Address { get set }     var sourceBus: Byte { get set }     var dataLength: Int { get set }     var reserved1: Byte { get set }     var reserved2: Byte { get set }     var command: Command { get set }     func toData() -> [Byte]  }  struct PeripheralHeader: PeripheralHeaderProtocol {     var destinationAdress: Address     var destinationBus: Byte     var sourceAddress: Address     var sourceBus: Byte     var dataLength: Int     var reserved1: Byte     var reserved2: Byte     var command: Command     init(destinationAdress: Address,          destinationBus: Byte,          sourceAddress: Address,          sourceBus: Byte,          dataLength: Int,          reserved1: Byte = 0x00,          reserved2: Byte = 0x00,          command: Command) {         self.destinationBus = destinationBus         self.destinationAdress = destinationAdress         self.sourceAddress = sourceAddress         self.sourceBus = sourceBus         self.dataLength = dataLength         self.reserved1 = reserved1         self.reserved2 = reserved2         self.command = command     }     func toData() -> [Byte] {         var header = destinationAdress.rawValue.value         header.append(destinationBus)         header.append(contentsOf: sourceAddress.rawValue.value)         header.append(sourceBus)         header.append(Byte(dataLength))         header.append(reserved1)         header.append(reserved2)         header.append(contentsOf: command.rawValue.value)         return header     } } 

Она хранит адреса (source и destination), длину ожидаемого сообщения, а также типы передаваемых параметров.

Все просто – есть протокол, которому соответствует структура, формирующая заголовок. 

Header имеет неизменяемую структуру. По большому счету, мы просто объединяем ВСЕ возможные варианты, раскладываем их по enum и выбираем, чем конкретно нам следует воспользоваться. 

Перейдем к более сложной части –  изменяемой.

Payload:

protocol PayloadProtocol {     var packetType: PacketType? { get set }     var object: Object? { get set }     var objects: [Object]? { get set }     var objectValue: IEEEFloat? { get set }     var valueData: Value? { get set }     func toData() -> [Byte] }  struct Payload: PayloadProtocol {     //Request Only Fields     var packetType: PacketType?     //Data type (full command model)     var object: Object?     //Multyple data type (full command model)     var objects: [Object]?     //Response only fields     // If packetClass Data     var dataType: DataType?      var valueData: Value?     // If packetClass MultiData     var valueDataArray: [Value]?     //If it for write data     var objectValue: IEEEFloat?     var rejectionStatus: RejectionStatus?     //Reaponse Init     // ToData to send     func toData() -> [Byte] {         var payload = packetType?.toData() ?? []         payload.append(contentsOf: object?.toData() ?? [])         payload.append(contentsOf: objects?.toByteArray() ?? [])         payload.append(contentsOf: objectValue?.toData() ?? [])         payload.append(contentsOf: dataType?.rawValue.value ?? [])         payload.append(contentsOf: rejectionStatus?.rawValue.value ?? [])         payload.append(contentsOf: valueData?.toData() ?? [])         return payload     } } 

В этой части передаются полезные данные, поэтому назовем ее “payload”. На ее состав влияет пара десятков (не преувеличиваю) свойств от типа и параметра запроса, до того, в каком типе данных мы будем передавать/принимать сообщение. В наследие получили 5 типов, и если честно, эта часть требует провести аналитику. 

Это непосредственно те данные, которые нам нужно считать/записать на девайс. Соответственно, они комбинируемые. К тому же, сообщения бывают single и multi (то есть, мы просим либо одно значение, либо несколько).  Также в этой части хранятся: тип данных, тип команды и непосредственно формат, в котором мы отправляем данные. Завершаем мы пакет сообщения контрольной суммой (про нее отдельно).

Самое приятное в части создания сообщений: понять типы кодировки и написать всевозможные хелперы для них. Ниже я приведу код реализации хелперов, полезных для девелоперов. Может пригодится.

Как было сказано, в проекте данные можно хранить в 5 различных типах, и все они отличаются набором передаваемых параметров.

Покажу на примере IEEFloat, как читать и конвертировать его в запрос.

struct IEEEFloat: IEEEFloatProtocol {     var forceCode: Byte?     var dataType: Byte?     var object: Object?     var statusCode: Byte?     var lowerLimitIEEEFloat: Float?     var upperLimitIEEEFloat: Float?     var dataValue: Float?        func toData() -> [Byte] {         var ieeeFloat: [Byte] = []         if let dataType = dataType {             ieeeFloat.append(dataType)         }         if let forceCode = forceCode {             ieeeFloat.append(forceCode)         }         if let statusCode = statusCode {             ieeeFloat.append(statusCode)         }         ieeeFloat.append(contentsOf: object?.toData() ?? [])         ieeeFloat.append(contentsOf: lowerLimitIEEEFloat?.bytes.reversed() ?? [])         ieeeFloat.append(contentsOf: upperLimitIEEEFloat?.bytes.reversed() ?? [])         ieeeFloat.append(contentsOf: dataValue?.bytes.reversed() ?? [])          return ieeeFloat     } }

Также уделю отдельное внимание калькулятору. На моей стороне это выглядело так:

import Foundation  typealias CRC = (crc: UInt16, crcLow: UInt16, crcHight: UInt16) typealias ByteCRC = (crc: Byte, crcLow: Byte, crcHight: Byte)  class CRCCalculator {     static func calculateCRCUInt16(data: Data) -> CRC {         let length = data.count         var crcHighByte: UInt16 = 0         var crcLowByte: UInt16 = 0         var carryFlag1: UInt16 = 0         var carryFlag2: UInt16 = 0         let numOfBytes = length         for byte in 0..<numOfBytes {             var crcTempByte = data[byte]             for _ in 0..<8 {                 carryFlag1 = 0                 if (crcTempByte & 0x01) == 1 {                     carryFlag1 = 1                 }                 crcTempByte = crcTempByte >> 1                 carryFlag1 = carryFlag1 ^ (crcLowByte & 0x01)                 if carryFlag1 == 1 {                     crcHighByte = crcHighByte ^ 0x40                     crcLowByte = crcLowByte ^ 0x02                 }                 carryFlag2 = 0                 if (crcHighByte & 0x01) == 1 {                     carryFlag2 = 1                 }                 crcHighByte = crcHighByte >> 1                 if carryFlag1 == 1 {                     crcHighByte = crcHighByte | 0x80                 }                 crcLowByte = crcLowByte >> 1                 if carryFlag2 == 1 {                     crcLowByte = crcLowByte | 0x80                 }             }         }       return ((crcHighByte * 256) + crcLowByte, crcLowByte, crcHighByte)     }     static func calculateCRCUInt8(data: Data) -> ByteCRC {         let res = CRCCalculator.calculateCRCUInt16(data: data)         let crc = res.crc.data.byteArray[0] as Byte         let crcLow = res.crcLow.data.byteArray[0] as Byte         let crcHight = res.crcHight.data.byteArray[0] as Byte         return (crc, crcLow, crcHight)     } }

Для того, чтобы не городить много бесполезного кода, процесс создания payload, header и подсчет CRC были вынесены в отдельные классы, отвечающие за общий тип сборки данных, а каждый частный соответствует общему протоколу, что делает код легко масштабируемым и изменяемым.

Дженеричность решения реализована с помощью протоколов. Каждый вид сообщения соответствует общему протоколу, что позволяет легко комбинировать их части. 

Немного притормозим и подведем первый итог.

Вот что же мы дописали? Ничего. Это просто две виртуальные коробки Lego. Из них можно собрать все что угодно, только нужна инструкция… Чувствуете, о чем я? Так давайте напишем класс, который будет иметь инструкцию, КАК собрать запрос. Он будет знать МЕХАНИЗМ, по которому он будет собирать, как из кубиков, наше сообщение, а также парсить полученный ответ. При этом, такой подход позволяет нам максимально универсально подходить к вопросу сборки сообщений. 

Достаточно слов – я приведу конкретный пример того, как собирается сообщение на ieefloat:

func toPeripheralPackageRequest(forPacketClass: PacketClass) -> PeripheralPacket? {     //ToDo: Here we always convert response     guard let destinationAdress = Address(rawValue: Uuid(value: Array(self.bytetoArray[0...3]))),           let sourceAddress = Address(rawValue: Uuid(value: Array(self.bytetoArray[5...8]))),           let command = Command(rawValue: Uuid(value: [self.bytetoArray[13]]))           else {         return nil     }     let sourceBusByte = self.bytetoArray[9]     let destinationBusByte = self.bytetoArray[4]     let dataLength = Int(self.bytetoArray[10])     let reserved1 = self.bytetoArray[11]     let reserved2 = self.bytetoArray[12]     let header = PeripheralHeader(destinationAdress: destinationAdress,                                        destinationBus: destinationBusByte,                                        sourceAddress: sourceAddress,                                        sourceBus: sourceBusByte,                                        dataLength: dataLength,                                        reserved1: reserved1,                                        reserved2: reserved2,                                        command: command)     switch command {     case .read:         switch forPacketClass {         case .data:             guard let packetProperty = DataProperty(rawValue: Uuid(value: [self.bytetoArray[15]])) else {                 return nil             }             let packetType = PacketType(packetClass: forPacketClass,                                              packetProperty: packetProperty)             let payload = Payload(packetType: packetType,                                        object: Object(objectNumberHighByte: self.bytetoArray[16],                                                            objectNumberLowByte: self.bytetoArray[17],                                                            objectName: Array(self.bytetoArray[18...25])))             return PeripheralPacket(header: header, payload: payload)         case .multiData:             // At this moment it is only ieee float elements, but in future it can be different types             let length = (Int(dataLength) - 2) / 10             var valueDataArray: [Value] = []             var itemLength = Int(self.bytetoArray[14])             for _ in 1...length {                 itemLength = Int(self.bytetoArray[14 + itemLength * length - 1])                 let ieeeFloat = IEEEFloat(forceCode: self.bytetoArray[15 + itemLength * length - 1],                                               statusCode: self.bytetoArray[16],                                               lowerLimitIEEEFloat: Data(Array(self.bytetoArray[17...20])).floatValue,                                               upperLimitIEEEFloat: Data(Array(self.bytetoArray[21...24])).floatValue,                                               dataValue: Data(Array(self.bytetoArray[25...28])).floatValue)                 let value = Value(dataType: .ieeeFloat, ieeeFloat: ieeeFloat)                 valueDataArray.append(value)             }             let payload = Payload(valueDataArray: valueDataArray)             return PeripheralPacket(header: header, payload: payload)         default:             return nil         }         ...     default:         return nil     } } 

Как вы видите, есть пакет и его компоненты. Для конкретного сообщения.

Все очень просто: начинаем сборку с header. Берем из enum destination и source адреса, выбираем также из перечисления команду, вычисляем длину сообщения, расставляем зарезервированные байты по стандарту. И… вызываем инициализатор. 

Аналогичную процедуру проделываем для payload, но! Я УЖЕ учитываю ТИП команды, ТИП запроса и расписываю КАЖДЫЙ способ сборки пакета (ниже приведу только один из 12 вариантов).

Собственно, это мультишот даты определенного типа. Лишнего нам и не нужно)

Отдельно разберу именно сборку ieeFloat: 

struct IEEEFloat: IEEEFloatProtocol {     var forceCode: Byte?     var dataType: Byte?     var object: Object?     var statusCode: Byte?     var lowerLimitIEEEFloat: Float?     var upperLimitIEEEFloat: Float?     var dataValue: Float?     func toData() -> [Byte] {         var ieeeFloat: [Byte] = []         if let dataType = dataType {             ieeeFloat.append(dataType)         }         if let forceCode = forceCode {             ieeeFloat.append(forceCode)         }         if let statusCode = statusCode {             ieeeFloat.append(statusCode)         }         ieeeFloat.append(contentsOf: object?.toData() ?? [])         ieeeFloat.append(contentsOf: lowerLimitIEEEFloat?.bytes.reversed() ?? [])         ieeeFloat.append(contentsOf: upperLimitIEEEFloat?.bytes.reversed() ?? [])         ieeeFloat.append(contentsOf: dataValue?.bytes.reversed() ?? [])          return ieeeFloat     } } 

Force code, Status code, нижний и верхний лимиты, а также значение.

Согласно приложенным инструкциям, калькулятор считает чек сумму в моменте инициализации сообщения и сборки массива. 

Прошу обратить внимание, что каждый сборщик имеет подобный метод:

func toData() -> [Byte] {         var packet = header.toData()         packet.append(contentsOf: payload.toData())         packet.append(crcLow)         packet.append(crcHight)         return packet }

Он переводит нашу модель в массив байт. Что в конечном итоге дает итоговый массив, который CoreBluetooth отправляет на девайс в указанную характеристику. Следующим способом: 

func write(bytes: [Byte], for Characteristic: Characteristic, type: CBCharacteristicWriteType = .withoutResponse) {         guard let characteristic = cbPeripheral?.characteristics.first(where: {             $0.Characteristic == Characteristic         }) else {             return         }         cbPeripheral?.writeValue(Data(bytes), for: characteristic, type: type)     } 

Мы берем конкретный массив байт и передаем его в известную характеристику, ожидая получить ответ после записи на девайс.

Конструкция получается достаточно громоздкой. Но в ней есть известные плюсы – легко масштабируемый код, в котором есть возможность изменить любое из свойств. 

Выводы

По факту статья получилась даже не совсем о Core Bluetooth, а скорее о том, какой инструмент можно создать для работы с порой катастрофически большим объёмом данных, которые можно последовательно передавать на девайс в легковесном формате байтовых массивов. 

Кроме того, я думаю Вы сможете воспользоваться простыми, но удобными хелперами для конвертации данных. 

До скорых встреч.  

Хелперы: 

extension Data {     var byteArray: [Byte] {         let bufferPointer = UnsafeBufferPointer(start: (self as NSData).bytes.assumingMemoryBound(to: Byte.self), count: count)         return Array(bufferPointer)     }     var floatValue: Float {         return Float(bitPattern: UInt32(bigEndian: self.withUnsafeBytes { $0.load(as: UInt32.self) }))     }          var intValue: Int {         return self.reduce(0) { value, byte in             return value << 8 | Int(byte)         }     }     var stringValue: String {         if let str = NSString(data: self, encoding: String.Encoding.utf8.rawValue) as String? {             return str         } else {             return ""         }      } }   extension UInt16 {     var data: Data {         var int = self         return Data(bytes: &int, count: MemoryLayout<UInt16>.size)     } }  extension Float {    var bytes: [UInt8] {        withUnsafeBytes(of: self, Array.init)    } }  func hexToString() -> String {                  var finalString = ""         let chars = Array(self)                  for count in stride(from: 0, to: chars.count - 1, by: 2){             let firstDigit =  Int.init("\(chars[count])", radix: 16) ?? 0             let lastDigit = Int.init("\(chars[count + 1])", radix: 16) ?? 0             let decimal = firstDigit * 16 + lastDigit             let decimalString = String(format: "%c", decimal) as String             finalString.append(Character.init(decimalString))         }         return finalString              }          func base64Decoded() -> String? {         guard let data = Data(base64Encoded: self) else { return nil }         return String(data: data, encoding: .init(rawValue: 0))     }      func ranges(of string: String) -> [NSRange] {         var ranges = [NSRange]()         var searchStartIndex = self.startIndex          while searchStartIndex < self.endIndex,             let range = self.range(of: string, range: searchStartIndex..<endIndex),             !range.isEmpty {              let nsRange = NSRange(range, in: self)             ranges.append(nsRange)             searchStartIndex = range.upperBound         }          return ranges     }      func rangeOfMatches(for pattern: String) -> [NSRange] {         do {             let regex = try NSRegularExpression(pattern: pattern)              return regex                 .matches(in: self, range: NSRange(startIndex..., in: self))                 .compactMap { $0.range }          } catch {             return []         }     }  


ссылка на оригинал статьи https://habr.com/ru/articles/572846/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *