Начну с себя. Я и есть тот самый 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 []
        }
    }


Комментарии (8)


  1. lenz1986
    16.08.2021 21:41

    2 раза перечитал но так и не понял..... Разве BLE не стандартизирован настолько что изобретать свой велосипед уже просто нет смысла?
    Или ваши инженеры со своей стороны изобретают велосипед и меняют стандартный алгоритм работы BLE?
    Там ведь все максимально просто и кучей готовых библиотек да и вроде уже даже самой системой разбирается


    1. EllNick Автор
      17.08.2021 18:19

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


      1. lenz1986
        17.08.2021 18:21

        Ну ок… используем ble nus и применяем конкретную характеристику уарт, базовую, системную, а уже по ней гоняем свои данные… все равно больше 20 байт за поток что так что так не пройдёт …


  1. Ritan
    17.08.2021 00:25

    Статья - три литра воды и 0 конкретики. Какой-то мифический Чип(tm)​ для которого есть Протокол​(tm) и Разработчик​(tm) должен передать Данные​(tm) на Чип(tm)​, чтобы сделать Что-то​(tm).


    1. EllNick Автор
      17.08.2021 18:20

      Есть понятие NDA. Глубоко описывать процессы я к сожалению не имею права. В целом конкретика такова, что Вы можете взять реалицию инициализации структр и использовать это у себя в проекте. Либо же это может использовать человек, который получит подобную задачу где ему предоставляют мультиданные и не изобретать снова идею а взять части этой.


      1. n0isy
        24.08.2021 17:44

        Я правильно понял, что статья это изобретение велосипеда, вместо использования protobuf, bson, etc... ?


  1. w-alena-w
    27.08.2021 02:15

    Данное преобразование можно использовать не только под core Bluetooth?


    1. EllNick Автор
      27.08.2021 02:15

      Элементы кода думаю да можно.