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