今是昨非

今是昨非

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

A Literary Society for iOS Bluetooth Development

Learning iOS Bluetooth Development#

Background#

Recently, I have been developing an APP to connect with Bluetooth devices. Here, I will share some important aspects to consider when connecting Bluetooth devices in iOS, which roughly includes the following areas:

  • Xcode Bluetooth permissions
  • How to scan for Bluetooth devices and obtain the Mac address
  • Switching between different Bluetooth devices
  • Writing Bluetooth commands
    • Convert data to hexadecimal string
    • Convert hexadecimal to String
    • CRC algorithm
    • Data XOR calculation, string XOR
      • Negative number XOR calculation
  • Sequentially writing multiple commands

General Process of Bluetooth Development#

First, let's understand the process of Bluetooth development, summarized as follows:

Xcode configuration of Bluetooth permissions -> Start Bluetooth -> Scan for nearby Bluetooth -> Connect to specified Bluetooth -> Verify if the connection is successful -> Bluetooth read/write -> Disconnect

The flowchart is as follows:

Bluetooth Flowchart

Specific Steps#

1. Configure Xcode Bluetooth Permissions#

  1. Under the General Tab, add CoreBluetooth.framework in Frameworks, Libraries, and Embedded Content, as shown below:
    addCoreBluetoothFramework

  2. Under the Signing & Capabilities Tab, check Uses Bluetooth LE accessories in Background Modes, as shown below:

    bluetoothBackgroundModes

  3. Under the Info Tab, add Privacy - Bluetooth Peripheral Usage Description and Privacy - Bluetooth Always Usage Description in Custom iOS Target Properties.

After completing the above steps, the Xcode Bluetooth configuration is complete. Now let's see how to initialize Bluetooth.

2. Bluetooth Initialization Call#

Before looking at the code, you can first check the mind map below, from iOS Bluetooth Knowledge Quick Start (Detailed Version).

iOS Bluetooth Knowledge Quick Start (Detailed Version)

With a general impression, let's look at the usage of CoreBluetooth in the lower right part.

Initialize CBCentralManager, which is responsible for Bluetooth initialization, scanning, and connection. The initialization method will prompt for Bluetooth permission request, which does not need to be explicitly declared.

 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. Scan for Nearby Bluetooth Devices#

After initializing CBCentralManager, call the method to scan for nearby Bluetooth devices to discover Bluetooth devices.
Ps: If the Bluetooth device has a low battery sleep function, you can prompt the user to manually activate Bluetooth here; otherwise, the connection may be slow or fail.

 // Start scanning
- (void)startScan {
    // Do not rescan already discovered devices
    NSDictionary *dic = @{
        CBCentralManagerScanOptionAllowDuplicatesKey: @NO,
        CBCentralManagerOptionShowPowerAlertKey: @YES
    };
     // Start scanning
    [self.centralManager scanForPeripheralsWithServices:nil options:dic];
}

Additionally, if there are no multiple Bluetooth devices switching back and forth nearby, you can use the following method to quickly connect to the last connected device. The retrieveConnectedPeripheralsWithServices method will retrieve the devices that have successfully connected via Bluetooth. These devices may not be connected by this APP, so extra caution is needed when using it.

// Start scanning
- (void)startScan {
   // Do not rescan already discovered devices
   NSDictionary *dic = @{
       CBCentralManagerScanOptionAllowDuplicatesKey: @NO,
       CBCentralManagerOptionShowPowerAlertKey: @YES
   };
    // Start scanning
    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 {
        // Start scanning
        [self.centralManager scanForPeripheralsWithServices:nil options:dic];
    }
}

4. Identify the Bluetooth Device to Connect#

Handle the discovered Bluetooth devices. Since CBCentralManager was initialized with a delegate, you need to implement the CBCentralManagerDelegate methods.

The centralManager:didDiscoverPeripheral:advertisementData:RSSI: method is the callback for discovering Bluetooth devices. In this method, you need to identify the Bluetooth device to connect to and then call the connection method.

It is important to note that in iOS Bluetooth, there is no way to directly obtain the Bluetooth device's Mac address, so the device provider needs to include the Bluetooth Mac address in the advertisementData. You need to confirm with the device manufacturer how to obtain this, for example, which field in advertisementData contains the Mac address, and which positions to take. Then you can first obtain the corresponding data, convert it to a hexadecimal string, and then use a fixed rule to extract the Mac address, and then determine the Bluetooth device to connect based on the Mac address.

Of course, you can also filter by simple Bluetooth names first, and then further confirm the unique device by Mac address. After finding the device to connect, call connectPeripheral:options: to initiate the connection.

After a successful connection, stop scanning for Bluetooth devices, set the Bluetooth device's delegate, and start scanning for services.

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

// Device discovered
- (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);
   // Check if the name contains the specified Bluetooth device name
   if (([nameStr caseInsensitiveCompare:@"TargetBluetoothName"] == NSOrderedSame)){
        // Example Bluetooth address is placed in `advertisementData`'s `kCBAdvDataManufacturerData`
        // First, get `kCBAdvDataManufacturerData`
       NSData *manufacturerData = [advertisementData valueForKey:@"kCBAdvDataManufacturerData"];
       // Then convert to hexadecimal string, this method will be provided later
       NSString *manufacturerDataStr = [BluetoothTool convertDataToHexStr:manufacturerData];
       if (manufacturerDataStr) {
            // Then extract the `Mac address` based on the rules
           NSString *deviceMacStr = [manufacturerDataStr substringWithRange:NSMakeRange(x, 12)];
           // Then check if the `obtained Mac address` matches the `Mac address` of the device to operate; if they match, connect
           if ([deviceMacStr caseInsensitiveCompare:targetDeviceMac] == NSOrderedSame) {
               [self.centralManager connectPeripheral:peripheral options:nil]; // Command to initiate connection
               self.peripheral = peripheral;
           }
       }
   }
}

// Connection successful
- (void)centralManager:(CBCentralManager *)central didConnectPeripheral:(CBPeripheral *)peripheral {
   // After successful connection, search for services; passing nil will search for all services
   [self.centralManager stopScan];
   peripheral.delegate = self;
   [peripheral discoverServices:nil];
}

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

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

5. Scan for Services of the Specified Bluetooth Device#

Handle the scanning of services. Note that since peripheral.delegate has been set, you need to implement the CBPeripheralDelegate methods.

peripheral:didDiscoverServices: is the callback for discovering services. In this callback method, you need to check if the found service UUID matches the service UUID of the device you want to connect to (this is provided by the Bluetooth device manufacturer or specified in the device documentation). If they match, continue to the next step to find characteristics.

peripheral:didDiscoverCharacteristicsForService:error: is the callback for discovering characteristics, used to obtain read and write characteristics. Read and write characteristics are also distinguished by UUIDs. Sometimes, read and write may also share the same UUID, which is also provided by the manufacturer or specified in the documentation. Ps: It is important to note that you need to pay attention to the documentation provided by the manufacturer; some devices require writing specific information after obtaining characteristics, and only then will the connection be considered truly successful.

peripheral:didUpdateValueForCharacteristic:error: is the callback for the Bluetooth device returning data, i.e., the callback for reading data. It is important to note that the operation of Bluetooth and ordinary command execution is different; it is not enough to just execute it; after writing the Bluetooth command, you need to determine whether the command was executed successfully based on the data returned by the Bluetooth device. Most complex logic is handled in this method because the data returned by this method is of type Data, which needs to be decrypted and converted to Byte or Hex Str for processing.

peripheral:didWriteValueForCharacteristic: is the callback for whether the command was successfully written, indicating that the command was successfully written to the Bluetooth device, meaning the Bluetooth device successfully received the command. However, whether the command was executed successfully needs to be determined based on the data returned by the above method.

The code is as follows:

#pragma mark - CBPeripheralDelegate
// Callback for discovering services
- (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) {
               // Discover characteristics for specific services
               [service.peripheral discoverCharacteristics:nil forService:service];
           }
       }
   }
}

// Callback for discovering characteristics, characteristics discovered by the previous service discovery (above), used to obtain read and write characteristics
- (void)peripheral:(CBPeripheral *)peripheral didDiscoverCharacteristicsForService:(CBService *)service error:(nullable NSError *)error {
   for (CBCharacteristic *characteristic in service.characteristics) {
      // Sometimes read and write operations are completed by one characteristic
      if ([characteristic.UUID.UUIDString caseInsensitiveCompare:TARGET_CHARACTERISTIC_UUID_READ] == NSOrderedSame) {
          self.read = characteristic;
          // Subscribe to the characteristic's response data
          [self.peripheral setNotifyValue:YES forCharacteristic:self.read];
      } else if ([characteristic.UUID.UUIDString caseInsensitiveCompare:TARGET_CHARACTERISTIC_UUID_WRITE] == NSOrderedSame) {
          self.write = characteristic;
          // Here, you need to pay attention to whether specific commands need to be executed based on actual conditions to determine if the connection is successful
          [self makeConnectWrite];
      }
   }
}

// Callback for reading data
- (void)peripheral:(CBPeripheral *)peripheral didUpdateValueForCharacteristic:(CBCharacteristic *)characteristic error:(nullable NSError *)error {
   if (error) {
       NSLog(@"=== Read error: %@",error);
       return;
   }
   
   if([characteristic.UUID.UUIDString caseInsensitiveCompare:Target_CHARACTERISTIC_UUID_READ] == NSOrderedSame){
        // Obtain the response data from the subscribed characteristic
        // The returned data is of type Data, convert Data to a hexadecimal string for processing, or convert to Byte for processing;
       NSString *value = [[BluetoothTool convertDataToHexStr:characteristic.value] uppercaseString];

        // According to the documentation, decrypt or process the data, and then use specified logic to determine whether the command was executed successfully

   }
}

// Callback for whether writing was successful
- (void)peripheral:(CBPeripheral *)peripheral didWriteValueForCharacteristic:(CBCharacteristic *)characteristic error:(nullable NSError *)error {
   if (error) {
       NSLog(@"=== Command write error: %@",error);
   }else{
       NSLog(@"=== Command write successful");
   }
}

6. Batch Write Multiple Commands#

If the Bluetooth device does not support asynchronous operations and does not support parallel writing, special attention is needed when batch writing multiple commands. You can create a queue and set the queue's dependency to specify that the write commands are executed one by one.

Helper Methods#

Most conversion methods are derived from IOS Bluetooth Communication Various Data Type Conversions, and can be used as needed.

Convert Data to Hexadecimal String

The data returned by Bluetooth is of type NSData. At this point, you can call the method below to convert NSData to a hexadecimal string, and then process the specified bits of the string.
Ps: It is important to note that since it is converted to a hexadecimal string for processing, there may be arithmetic operations needed later, so it is best to convert to a string and uniformly convert it to uppercase.

  // Convert NSData to a hexadecimal string, <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;
}

Convert Decimal Number to Hexadecimal String, mainly used for bitwise operations, can be converted to String and then processed by String based on Range.

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

Convert Hexadecimal String to Decimal Number, used in cases where arithmetic operations are needed, first convert the string to a decimal number, perform the operation, and then convert back to a hexadecimal string. Ps: When converting here, be careful; if the number after arithmetic operations is less than 0, directly converting the decimal number to a hexadecimal string using the above method will cause issues.

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

Special handling for negative numbers after arithmetic operations is as follows:

NSInteger num = num - randNum;
if (num < 0) {
    // If the number is less than 0, use 256 + this negative number, then take the result to convert to a hexadecimal string
    num = 256 + num;
}
NSString *hexStr = [[NSString stringWithFormat:@"%02lx", (long)num] uppercaseString];

String XOR Method

Since Data has been converted to a string, XOR needs to be performed on the string. Refer to iOS XOR Operation on Two Equal Length Strings, removing the length equality check and changing it to bitwise XOR.

Ps: It is important to note the case of negative numbers.

+ (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 Algorithm

Pay attention to the verification algorithm provided by the manufacturer. If it is CRC8 verification, you can refer to the code below, but also check if it is CRC8 maxin verification; it is best to try it online. The following code refers to CRC8 Verification in iOS Bluetooth Development, which is CRC8 maxin verification.

// CRC8 Verification
+ (NSString *)crc8_maxin_byteCheckWithHexString:(NSString*)hexString {
    NSString * tempStr = hexString;
    NSArray *tempArray = [self getByteForString:hexString];
    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];
}

Convert Hexadecimal String to Data

This method is used to send commands to Bluetooth. Since all logic is processed as hexadecimal strings, and Bluetooth devices only accept Data, it is necessary to convert the hexadecimal string to Data before sending it to Bluetooth.

Ps: It is best to convert the string to uppercase before converting to Data.

// Convert a hexadecimal string to NSData, the input string is converted to 128-bit characters, and if it is insufficient, fill in numbers; if corresponding bits are needed, just truncate the position. @"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;
}

Pitfalls#

  • Bluetooth initialization crash, Assertion failure in -[CBCentralManager initWithDelegate:queue:options:], CBCentralManage...

    This is because the new project did not enable Bluetooth permissions. Check Use Bluetooth LE accessories under Background Modes in Project -> Target -> Signing & Capabilities, as shown below:
    bluetoothcrash

  • Confusion when switching between multiple devices

    When switching back and forth between multiple devices, there may be confusion, i.e., originally connected to Bluetooth device 1, and then sending commands to Bluetooth device 2, resulting in the command operating on Bluetooth device 1. Initially, it was thought that the disconnect method was not called or the disconnection time was not long enough. After investigation, it was found that this was caused by using retrieveConnectedPeripheralsWithServices. After each disconnection, when reconnecting, the first device obtained through retrieveConnectedPeripheralsWithServices is still the recently disconnected device, so the wrong Bluetooth device is connected again.

  • Incorrect XOR results

    Another issue encountered during development was that, despite the logic and encryption algorithm being correct, commands occasionally failed. Initially, it was thought to be a problem with the Bluetooth device, as some commands succeeded while others did not. After investigation, it was found that the arithmetic operations involved in the algorithm caused issues when negative numbers were present, leading to command failures. The solution was to handle negative numbers by converting them to positive values using (256 + negative number) before converting to hexadecimal for XOR calculations.

  • After going live, users reported that when the APP enters the background, the following message appears:

    『xxx』wants to use Bluetooth for new connections; you can allow new connections in settings.

    Initially, it was thought that there was Bluetooth activity in the background. After investigation, it was found that the method for disconnecting Bluetooth was called when entering the background. Therefore, it was not a background activity issue. After communicating with users, it was discovered that the Bluetooth switch was turned off, and this prompt appeared when entering the background. When turned on, there was no issue. This was because the disconnect method by default used the initialized CBCentralManager without checking if the Bluetooth switch was on.

Summary#

When connecting Bluetooth devices, first configure Bluetooth permissions in Xcode, then thoroughly read the documentation provided by the device manufacturer, paying special attention to how the Bluetooth device's Mac address is provided, whether the service UUID and read/write UUID of the Bluetooth device are provided, how to determine if Bluetooth is successfully connected, and the methods for command encryption and decryption. Then, initialize Bluetooth using the system-provided methods, encapsulate the methods for handling Bluetooth operation commands and encryption/decryption methods. Finally, after everything is completed, remember to disconnect the Bluetooth device.

References#

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.