Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Начну с себя. Я и есть тот самый 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 []
}
}