今是昨非

今是昨非

日出江花红胜火,春来江水绿如蓝

一文學會iOS藍牙開發

一文學會 iOS 藍牙開發#

背景#

最近做 APP 對接藍牙設備開發,這裡分享一下 iOS 對接藍牙設備中需要注意的東西,大致包含下面這些方面:

  • Xcode 藍牙權限
  • 如何掃描藍牙設備、獲取 Mac 地址
  • 不同藍牙設備切換
  • 寫入藍牙命令
    • data 轉 16 進制字符串
    • 16 進制轉 String
    • crc 算法
    • 數據異或計算,字符串異或
      • 負數異或計算
  • 依次寫入多個命令

藍牙開發的大致流程#

首先來了解一下藍牙開發的流程,總結如下:

Xcode 配置藍牙權限 -> 啟動藍牙 -> 掃描周圍藍牙 -> 連接指定藍牙 -> 校驗是否連接成功 -> 藍牙讀 / 寫 -> 斷開連接

流程圖如下:

藍牙流程圖

具體步驟#

1. 配置 Xcode 藍牙權限#

  1. General Tab 下,Frameworks,Libraries, and Embedded Content中添加 CoreBluetooth.framework,如下圖:
    addCoreBluetoothFramework

  2. Signing & Capabilities Tab 下,Background Modes中,勾選Uses Bluetooth LE accessories,如下圖:

    bluetoothBackgroundModes

  3. Info Tab 下,Custom iOS Target Properties中,添加Privacy - Bluetooth Peripheral Usage DescriptionPrivacy - Bluetooth Always Usage Description

完成上面的步驟後,Xcode 藍牙配置就完成了,然後來看如何初始化藍牙。

2. 初始化藍牙調用#

再開始看代碼前,可以先看下面的思維導圖,來自iOS 藍牙知識快速入門(詳盡版)

iOS 藍牙知識快速入門(詳盡版)

有了大致印象後,然後來看右下那部分 CoreBluetooth 的使用。

初始化 CBCentralManagerCBCentralManager負責的是藍牙初始化、掃描、連接,初始化方法中會彈出申請藍牙權限申請,不需要顯式聲明

 dispatch_queue_t queue = dispatch_queue_create("com.queue.bluetooth", DISPATCH_QUEUE_SERIAL);
 NSDictionary *dic = @{
     CBCentralManagerOptionShowPowerAlertKey: @YES,
     CBCentralManagerOptionRestoreIdentifierKey: @"com.queue.bluetooth"
 };
 self.centralManager = [[CBCentralManager alloc] initWithDelegate:self queue:queue options:dic];

3. 掃描周邊藍牙設備#

掃描周邊藍牙設備,CBCentralManager初始化之後,調用掃描周邊藍牙設備方法,掃描發現藍牙設備。
Ps: 如果藍牙設備有低電量休眠功能,可以在這裡提示用戶手動先激活藍牙,否則連接比較慢,或鏈接不上

 // 開始掃描
- (void)startScan {
    // 不重複掃描已發現設備
    NSDictionary *dic = @{
        CBCentralManagerScanOptionAllowDuplicatesKey: @NO,
        CBCentralManagerOptionShowPowerAlertKey: @YES
    };
     // 開始掃描
    [self.centralManager scanForPeripheralsWithServices:nil options:dic];
}

另外,如果不存在附近多台藍牙設備來回切換的情況,可以用如下方法,快速連接上次連接過的設備。retrieveConnectedPeripheralsWithServices方法會獲取藍牙連接成功的設備,這些設備可能不是本 APP 連接的,所以使用時需要額外注意。

// 開始掃描
- (void)startScan {
   // 不重複掃描已發現設備
   NSDictionary *dic = @{
       CBCentralManagerScanOptionAllowDuplicatesKey: @NO,
       CBCentralManagerOptionShowPowerAlertKey: @YES
   };
    // 開始掃描
    NSArray *arr = [self.centralManager retrieveConnectedPeripheralsWithServices:@[[CBUUID UUIDWithString:Target_SERVICE_UUID]]];
    if (arr.count > 0) {
        CBPeripheral *per = arr.firstObject;
        self.peripheral = per;
        [self.centralManager connectPeripheral:per options:nil];
    } else {
        // 開始掃描
        [self.centralManager scanForPeripheralsWithServices:nil options:dic];
    }
}

4. 識別要連接藍牙設備#

掃描到藍牙設備的處理。CBCentralManager初始化時設置了delegate,所以需要實現CBCentralManagerDelegate的代理方法。

其中centralManager:didDiscoverPeripheral:advertisementData:RSSI:方法是發現藍牙設備的回調方法,在這個方法裡,需要識別出要鏈接的藍牙設備,然後調用連接方法。

這裡需要注意的是,iOS 的藍牙,沒有辦法直接獲取藍牙設備的Mac地址,所以需要提供設備方將藍牙 Mac 地址,放到advertisementData中提供,這裡需要跟設備廠商確認好,獲取邏輯,例如advertisementData中哪個字段中包含有Mac地址,取值是第幾位到第幾位。然後可以先獲取到對應的 data,再轉為十六進制的 hex string, 再通過固定的規則取到Mac地址,然後根據Mac地址確定要鏈接的藍牙設備。

當然也可以先通過簡單的藍牙名字過濾,然後再通過Mac地址進行進一步的確認唯一設備,找到要鏈接的設備後,再調用connectPeripheral:options:發起連接。

連接成功後,停止掃描藍牙設備,設置藍牙設備的代理,開始掃描服務。

#pragma mark - CBCentralManagerDelegate
- (void)centralManagerDidUpdateState:(CBCentralManager *)central {
   
}

// 掃描到設備
- (void)centralManager:(CBCentralManager *)central didDiscoverPeripheral:(CBPeripheral *)peripheral advertisementData:(NSDictionary<NSString *,id> *)advertisementData RSSI:(NSNumber *)RSSI {
   if (peripheral == nil) {
       return;
   }
   if((__bridge CFUUIDRef )peripheral.identifier == NULL) return;
   
   NSString *nameStr = peripheral.name;
   if(nameStr == nil || nameStr.length == 0) return;
   NSLog(@"nameStr: %@", nameStr);
   NSLog(@"advertisementData: %@", advertisementData);
   // 判斷名字是否包含指定藍牙設備名字
   if (([nameStr caseInsensitiveCompare:@"TargetBluetoothName"] == NSOrderedSame)){
        // 示例藍牙地址放在`advertisementData`的`kCBAdvDataManufacturerData`中
        // 先取到`kCBAdvDataManufacturerData`
       NSData *manufacturerData = [advertisementData valueForKey:@"kCBAdvDataManufacturerData"];
       // 然後轉為十六進制字符串,這個方法後面提供
       NSString *manufacturerDataStr = [BluetoothTool convertDataToHexStr:manufacturerData];
       if (manufacturerDataStr) {
            // 然後根據規則,取出`Mac地址`
           NSString *deviceMacStr = [manufacturerDataStr substringWithRange:NSMakeRange(x, 12)];
           // 然後判斷`獲取到的Mac地址`和要操作設備的`Mac地址`是否一致,一致則連接
           if ([deviceMacStr caseInsensitiveCompare:targetDeviceMac] == NSOrderedSame) {
               [self.centralManager connectPeripheral:peripheral options:nil]; //發起連接的命令
               self.peripheral = peripheral;
           }
       }
   }
}

// 連接成功
- (void)centralManager:(CBCentralManager *)central didConnectPeripheral:(CBPeripheral *)peripheral {
   //連接成功之後尋找服務,傳nil會尋找所有服務
   [self.centralManager stopScan];
   peripheral.delegate = self;
   [peripheral discoverServices:nil];
}

// 連接失敗
- (void)centralManager:(CBCentralManager *)central didFailToConnectPeripheral:(CBPeripheral *)peripheral error:(NSError *)error {
   self.isConnected = NO;
   NSLog(@"鏈接失敗:%@", error.localizedDescription);
}

// 斷開連接
- (void)centralManager:(CBCentralManager *)central didDisconnectPeripheral:(CBPeripheral *)peripheral error:(NSError *)error {
   self.isConnected = NO;
   NSLog(@"斷開鏈接:%@", error.localizedDescription);
}

5. 掃描指定藍牙設備的服務#

掃描服務的處理。注意上面設置了peripheral.delegate,所以需要實現CBPeripheralDelegate的代理方法。

peripheral:didDiscoverServices:是發現服務的回調,在這個回調方法裡,需要判斷找到的服務 UUID 和要連接設備的服務 UUID(這個是提供藍牙設備的廠商會提供,或者設備文檔裡會標明)是否一致,一致則繼續下一步查找特徵值。

peripheral:didDiscoverCharacteristicsForService:error:是發現特徵的回調,用於獲取讀和寫的特徵。讀和寫的特徵也是有 UUID 區分,有時候讀和寫也是同一個 UUID,同樣是由廠商提供,或者文檔標明。Ps: 這裡需要注意的是,需要注意廠商提供的文檔,有些廠商的設備獲取到特徵之後,需要寫入指定信息,獲取到指定的返回才算真正的連接成功

periphera:didUpdateValueForCharacteristic:error:是藍牙設備返回數據的回調,即讀數據的回調。這裡需要注意,和藍牙的操作和普通的執行命令不同,不是執行了就可以了;寫入藍牙執行命令後,要根據藍牙設備返回數據判斷命令是否執行成功。大部分複雜的邏輯都在這個方法裡,因為這個方法返回的數據是 Data,需要將數據解密然後轉為 Byte 或者 Hex Str 進行處理。

peripheral:didWriteValueForCharacteristic:是命令是否寫入成功的回調,成功標明指令成功寫入到的藍牙設備,即藍牙設備成功收到了指令,但是指令是否執行成功是要根據上面的返回數據的方法判斷。

代碼如下:

#pragma mark - CBPeripheralDelegate
//發現服務的回調
- (void)peripheral:(CBPeripheral *)peripheral didDiscoverServices:(nullable NSError *)error {
   if (!error) {
       for (CBService *service in peripheral.services) {
           NSLog(@"serviceUUID:%@", service.UUID.UUIDString);
           
           if ([service.UUID.UUIDString caseInsensitiveCompare:TARGET_SERVICE_UUID] == NSOrderedSame) {
               //發現特定服務的特徵值
               [service.peripheral discoverCharacteristics:nil forService:service];
           }
       }
   }
}

//  發現特徵的回調,  發現characteristics,由發現服務調用(上一步),獲取讀和寫的characteristics
- (void)peripheral:(CBPeripheral *)peripheral didDiscoverCharacteristicsForService:(CBService *)service error:(nullable NSError *)error {
   for (CBCharacteristic *characteristic in service.characteristics) {
      //有時讀寫的操作是由一個characteristic完成
      if ([characteristic.UUID.UUIDString caseInsensitiveCompare:TARGET_CHARACTERISTIC_UUID_READ] == NSOrderedSame) {
          self.read = characteristic;
          //  訂閱特徵回覆的數據
          [self.peripheral setNotifyValue:YES forCharacteristic:self.read];
      } else if ([characteristic.UUID.UUIDString caseInsensitiveCompare:TARGET_CHARACTERISTIC_UUID_WRITE] == NSOrderedSame) {
          self.write = characteristic;
          // 這裡需要注意,根據實際情況,看是否需要執行特定指令,用於判斷是否連接成功
          [self makeConnectWrite];
      }
   }
}

//讀數據的回調
- (void)peripheral:(CBPeripheral *)peripheral didUpdateValueForCharacteristic:(CBCharacteristic *)characteristic error:(nullable NSError *)error {
   if (error) {
       NSLog(@"===讀取錯誤:%@",error);
       return;
   }
   
   if([characteristic.UUID.UUIDString caseInsensitiveCompare:Target_CHARACTERISTIC_UUID_READ] == NSOrderedSame){
        //獲取訂閱特徵回覆的數據
        // 返回的數據是Data類型,將Data轉為16進制的字符串用於處理,或者轉為 Byte 進行處理;
       NSString *value = [[BluetoothTool convertDataToHexStr:characteristic.value] uppercaseString];

        // 根據文檔,對於數據進行解密或者其他處理,然後通過指定的判斷邏輯,判斷是否指令是否執行成功

   }
}

//是否寫入成功的回調
- (void)peripheral:(CBPeripheral *)peripheral didWriteValueForCharacteristic:(CBCharacteristic *)characteristic error:(nullable NSError *)error {
   if (error) {
       NSLog(@"===指令寫入錯誤:%@",error);
   }else{
       NSLog(@"===指令寫入成功");
   }
}

6. 批量寫入多個指令#

如果藍牙設備不支持異步,且不支持並行寫入,需要批量寫入多個指令時需注意。可以通過創建隊列,設置隊列 dependency 的方式,指定寫入指令依次一個個執行。

輔助方法#

大部分轉換方法來自IOS 藍牙通信各種數據類型之間的轉換,使用時按需使用即可。

Data 轉 16 進制字符串

藍牙返回的數據是 NSData 類型,此時可以調用下面方法將 NSData 轉為 16 進制字符串,然後針對字符串取指定位進行處理。
Ps: 這裡需要注意,由於轉為 16 進制字符串處理,可能後面有需要進行算術運算,所以最好轉為字符串後,統一轉為大寫處理。

  // 將NSData轉為16進制的字符串, <0x00adcc asdfgwerf asdddffdfd> -> @"0x00adccasdfgwerfasdddffdfd"
+ (NSString *)convertDataToHexStr:(NSData *)data {
    if (!data || [data length] == 0) {
        return @"";
    }
    NSMutableString *string = [[NSMutableString alloc] initWithCapacity:[data length]];
    
    [data enumerateByteRangesUsingBlock:^(const void *bytes, NSRange byteRange, BOOL *stop) {
        unsigned char *dataBytes = (unsigned char*)bytes;
        for (NSInteger i = 0; i < byteRange.length; i++) {
            NSString *hexStr = [NSString stringWithFormat:@"%x", (dataBytes[i]) & 0xff];
            if ([hexStr length] == 2) {
                [string appendString:hexStr];
            } else {
                [string appendFormat:@"0%@", hexStr];
            }
        }
    }];
    return string;
}

十進制數字轉 16 進制字符串,主要用於按位操作,可以通過轉為 String,然後通過 String 按 Range 進行位操作。

NSString *hexStr = [NSString stringWithFormat:@"%02lx", (long)number];

16 進制字符串轉十進制數字,用於需要進行算術運算的情況,需要先將字符串轉為十進制數字,運算後,再轉為 16 進制字符串。Ps: ** 在這裡轉換時需要注意,如果算術運算後的數字小於 0 時,直接把十進制數字通過上面方法轉 16 進制字符串再去異或會有問題。

NSInteger num = strtoul(hexStr.UTF8String, 0, 16);

針對算術運算後小於 0 的數字的特殊處理如下:

NSInteger num = num - randNum;
if (num < 0) {
    // 如果數字小於0,則用256+這個負數,再拿結果去轉16進制字符串
    num = 256 + num;
}
NSString *hexStr = [[NSString stringWithFormat:@"%02lx", (long)num] uppercaseString];

字符串異或方法

由於將 Data 轉為了字符串,所以異或時需要對字符串進行異或,參考iOS 對兩個相等長度的字符串進行異或運算,移除長度相等判斷,改為按位異或

Ps:這裡需要注意負數的情況

+ (NSString *)xorPinvWithHexString:(NSString *)hexStr withPinv:(NSString *)pinv {
    NSString *resultStr;
    NSRange range = NSMakeRange(0, 2);
    for (NSInteger i = range.location; i < [hexStr length]; i += 2) {
        unsigned int anInt;
        NSString *hexCharStr = [hexStr substringWithRange:range];
        NSString *pinxStr = [BluetoothTool pinxCreator:hexCharStr withPinv:pinv];
        if (resultStr == nil) {
            resultStr = pinxStr;
        } else {
            resultStr = [NSString stringWithFormat:@"%@%@", resultStr, pinxStr];
        }
        range.location += range.length;
        range.length = 2;
    }
    return resultStr;
}

+ (NSString *)pinxCreator:(NSString *)pan withPinv:(NSString *)pinv
{
    if (pan.length != pinv.length)
    {
        return nil;
    }
    const char *panchar = [pan UTF8String];
    const char *pinvchar = [pinv UTF8String];
    
    NSString *temp = [[NSString alloc] init];
    
    for (int i = 0; i < pan.length; i++)
    {
        int panValue = [self charToint:panchar[i]];
        int pinvValue = [self charToint:pinvchar[i]];
        
        temp = [temp stringByAppendingString:[NSString stringWithFormat:@"%X",panValue^pinvValue]];
    }
    return temp;
}

+ (int)charToint:(char)tempChar
{
    if (tempChar >= '0' && tempChar <='9')
    {
        return tempChar - '0';
    }
    else if (tempChar >= 'A' && tempChar <= 'F')
    {
        return tempChar - 'A' + 10;
    }
    
    return 0;
}

CRC8 算法

注意廠商提供的校驗算法,如果是 CRC8 校驗的,可以參考下面的,但是還要注意是否是 CRC8 maxin 校驗,最好可以在線嘗試下。下面的代碼參考iOS 藍牙開發中的 CRC8 校驗,是 CRC8 maxin 校驗。

// CRC8校驗
+ (NSString *)crc8_maxin_byteCheckWithHexString:(NSString*)hexString {
    NSString * tempStr = hexString;
    NSArray *tempArray = [self getByteForString:hexString];
        //    NSArray  * tempArray = [tempStr componentsSeparatedByString:@" "];//分隔符
    unsigned char testChars[(int)tempArray.count];
    for(int i=0;i<tempArray.count;i++){
        NSString * string = tempArray[i];
        unsigned char fristChar = [self hexHighFromChar:[string characterAtIndex:0]];
        unsigned char lastChar  = [self hexLowFromChar:[string characterAtIndex:1]];
        unsigned char temp = fristChar+lastChar;
        testChars[i] = temp;
    }
    unsigned char res = [self crc8_maxin_checkWithChars:testChars length:(int)tempArray.count];
    return [NSString stringWithFormat:@"%x", res];
}

+(unsigned char)hexHighFromChar:(unsigned char) tempChar{
    unsigned char temp = 0x00;
    switch (tempChar) {
        case 'a':temp = 0xa0;break;
        case 'A':temp = 0xA0;break;
        case 'b':temp = 0xb0;break;
        case 'B':temp = 0xB0;break;
        case 'c':temp = 0xc0;break;
        case 'C':temp = 0xC0;break;
        case 'd':temp = 0xd0;break;
        case 'D':temp = 0xD0;break;
        case 'e':temp = 0xe0;break;
        case 'E':temp = 0xE0;break;
        case 'f':temp = 0xf0;break;
        case 'F':temp = 0xF0;break;
        case '1':temp = 0x10;break;
        case '2':temp = 0x20;break;
        case '3':temp = 0x30;break;
        case '4':temp = 0x40;break;
        case '5':temp = 0x50;break;
        case '6':temp = 0x60;break;
        case '7':temp = 0x70;break;
        case '8':temp = 0x80;break;
        case '9':temp = 0x90;break;
        default:temp = 0x00;break;
    }
    return temp;
}

+(unsigned char)hexLowFromChar:(unsigned char) tempChar{
    unsigned char temp = 0x00;
    switch (tempChar) {
        case 'a':temp = 0x0a;break;
        case 'A':temp = 0x0A;break;
        case 'b':temp = 0x0b;break;
        case 'B':temp = 0x0B;break;
        case 'c':temp = 0x0c;break;
        case 'C':temp = 0x0C;break;
        case 'd':temp = 0x0d;break;
        case 'D':temp = 0x0D;break;
        case 'e':temp = 0x0e;break;
        case 'E':temp = 0x0E;break;
        case 'f':temp = 0x0f;break;
        case 'F':temp = 0x0F;break;
        case '1':temp = 0x01;break;
        case '2':temp = 0x02;break;
        case '3':temp = 0x03;break;
        case '4':temp = 0x04;break;
        case '5':temp = 0x05;break;
        case '6':temp = 0x06;break;
        case '7':temp = 0x07;break;
        case '8':temp = 0x08;break;
        case '9':temp = 0x09;break;
        default:temp = 0x00;break;
    }
    return temp;
}

+ (NSArray *)getByteForString:(NSString *)string {
  NSMutableArray *strArr = [NSMutableArray array];
  for (int i = 0; i < string.length/2; i++) {
      NSString *str = [string substringWithRange:NSMakeRange(i * 2, 2)];
      [strArr addObject:str];
  }
  return [strArr copy];
}

16 進制字符串轉為 Data

這個方法用於發送指令給藍牙,由於所有邏輯都是轉為 16 進制字符串處理的,而藍牙設備只接收 Data,所以需要將 16 進制字符串轉為 Data,再發送給藍牙。

Ps:這裡最好也先將字符串轉為大寫,再轉為 Data

// 將16進制的字符串轉為NSData, 傳入的字符串轉為128位字符,不足位補數字,如果需要對應位,截取位置即可。 @"0a1234 0b23454" -> <0a1234 0b23454>
+ (NSData *)convertHexStrToData:(NSString *)hexStr {
    if (!hexStr || [hexStr length] == 0) {
        return nil;
    }
    
    NSMutableData *hexData = [[NSMutableData alloc] initWithCapacity:20];
    NSRange range;
    if ([hexStr length] % 2 == 0) {
        range = NSMakeRange(0, 2);
    } else {
        range = NSMakeRange(0, 1);
    }
    for (NSInteger i = range.location; i < [hexStr length]; i += 2) {
        unsigned int anInt;
        NSString *hexCharStr = [hexStr substringWithRange:range];
        NSScanner *scanner = [[NSScanner alloc] initWithString:hexCharStr];
        
        [scanner scanHexInt:&anInt];
        NSData *entity = [[NSData alloc] initWithBytes:&anInt length:1];
        [hexData appendData:entity];
        
        range.location += range.length;
        range.length = 2;
    }
    return hexData;
}

踩坑#

  • 藍牙初始化崩潰,Assertion failure in -[CBCentralManager initWithDelegate:queue:options:], CBCentralManage...

    是因為新建項目沒有開啟藍牙權限,將 Project -> Target -> Signing & Capabilities 中 Background Modes 下Use Bluetooth LE accessories勾選上即可,如下圖所示:
    bluetoothcrash

  • 多台設備切換連接錯亂

    多台設備來回切換時發現有錯亂的情況,即原來是連接的藍牙設備 1,然後針對藍牙設備 2 發送指令,結果指令操作到了藍牙設備 1 上,起初以為是沒有調用斷開連接的方法,或者斷開的時間不夠久。排查後發現是因為用了retrieveConnectedPeripheralsWithServices導致的。每次斷開連接後,再次連接時,通過retrieveConnectedPeripheralsWithServices獲取到的第一個設備仍是剛剛斷開連接的設備,所以再次連接時,就連接了錯誤的藍牙設備。

  • 異或結果錯誤

    在開發中還遇到了另外一個問題,就是邏輯和加密算法都沒問題的情況下,偶爾出現指令失效的情況。起初以為是藍牙設備的問題,因為有些指令能成功,而有些不能。排查後發現,是因為算法中涉及算術運算部分,出現負數時,指令就會失敗,再仔細研究後發現,是負數轉 16 進制再去異或運算時,出現問題。解決辦法是,針對出現負數的情況,改為 (256 + 負數) 轉為正值,然後再轉 16 進制再去異或計算。

  • 上線後,有用戶反饋,APP 進入後台時,提示如下信息

    『xxx』想要使用藍牙進行新連接,您可以在設置中允許新的連接。

    一開始以為是後台有藍牙活動,排查後發現,進入後台時會調用,藍牙斷開連接的方法。所以不是後台活動的問題。和用戶溝通後發現是用戶藍牙開關關閉,進入後台會提示這個,打開時就沒有這個問題。是因為在斷開連接的方法裡,默認使用了初始化的CBCentralManager,而沒有判斷藍牙開關是否開啟。

總結#

在對接藍牙設備時,首先需要在 Xcode 中配置藍牙權限,然後通讀設備廠商提供的文檔,著重注意藍牙設備的 Mac 地址如何提供,藍牙設備的服務 UUID 和讀寫 UUID 是否提供,如何判斷藍牙是否鏈接成功,以及指令加解密方法等。然後再通過系統提供的方法初始化藍牙,封裝處理藍牙操作指令的方法和加解密方法。最後當所有完成後,記得斷開藍牙設備的鏈接。

參考#

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。