今是昨非

今是昨非

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

iOS Bluetooth Development in Literature Society

Learning iOS Bluetooth Development#

Background#

Recently, I have been developing an app that interfaces with Bluetooth devices. Here, I would like to share some important aspects to consider when connecting iOS to Bluetooth devices, which generally include the following areas:

  • Xcode Bluetooth permissions
  • How to scan for Bluetooth devices and obtain MAC addresses
  • Switching between different Bluetooth devices
  • Writing Bluetooth commands
    • Converting data to hexadecimal strings
    • Converting hexadecimal to String
    • CRC algorithm
    • Data XOR calculations, string XOR
      • XOR calculations for negative numbers
  • 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 devices -> 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 in the image below:
    addCoreBluetoothFramework

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

    bluetoothBackgroundModes

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

After completing the above steps, the Xcode Bluetooth configuration is complete. Next, let's look at how to initialize Bluetooth.

2. Bluetooth Initialization Call#

Before looking at the code, you can first view the following mind map, 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 connecting. The initialization method will prompt for Bluetooth permission, 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. Scanning 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 first; 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 to switch between nearby, you can use the following method to quickly connect to the last connected device. The retrieveConnectedPeripheralsWithServices method will retrieve devices that have successfully connected via Bluetooth. These devices may not be connected by this app, so 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. Identifying 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 iOS Bluetooth does not allow direct access to the Bluetooth device's MAC address, so the device side needs to provide the Bluetooth MAC address in the advertisementData. You need to confirm with the device manufacturer about the retrieval logic, such as which field in advertisementData contains the MAC address, and the range of values to extract. You can then obtain the corresponding data, convert it to a hexadecimal string, and use a fixed rule to extract the MAC address, which will help determine the Bluetooth device to connect to.

You can also filter by simple Bluetooth names first and then confirm the unique device further using the MAC address. After finding the device to connect to, 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 a 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 `retrieved MAC address` matches the `MAC address` of the device to operate on; 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 a successful connection, look for services; passing nil will look 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. Scanning 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.

The peripheral:didDiscoverServices: method 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 will be provided by the Bluetooth device manufacturer or indicated in the device documentation). If they match, proceed to find the characteristic values.

The peripheral:didDiscoverCharacteristicsForService:error: method 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 share the same UUID, which is also provided by the manufacturer or indicated 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 specific information to be written after obtaining characteristics, and only then is the connection considered truly successful.

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

The peripheral:didWriteValueForCharacteristic: method is the callback for whether the command was successfully written, indicating that the instruction was successfully written to the Bluetooth device, meaning the Bluetooth device successfully received the instruction. However, whether the instruction was executed successfully needs to be determined based on the returned data from 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 the specific service
               [service.peripheral discoverCharacteristics:nil forService:service];
           }
       }
   }
}

// Callback for discovering characteristics, characteristics are discovered by the previous service discovery
- (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 instructions need to be executed 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){
        // Get 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 if the instruction 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 Writing 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. You can call the following method to convert NSData to a hexadecimal string, and then process specific 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 then uniformly convert 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 Range.

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

Convert Hexadecimal String to Decimal Number, used for situations requiring arithmetic operations, first convert the string to a decimal number, perform the operation, and then convert back to a hexadecimal string. Ps: When converting here, it is important to note that if the number after arithmetic operations is less than 0, directly converting the decimal number to a hexadecimal string for XOR 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 following, but also note whether it is CRC8 maxin verification; it is best to try it online. The following code references 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, as all logic is processed as hexadecimal strings, while Bluetooth devices only accept Data, so 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 slice 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 the Project -> Target -> Signing & Capabilities and check Use Bluetooth LE accessories under Background Modes, as shown in the image below:
    bluetoothcrash

  • Confusion when switching connections between multiple devices

    When switching 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 that the disconnection time was not long enough. After investigation, it was found that this was caused by using retrieveConnectedPeripheralsWithServices. Each time after disconnecting, when reconnecting, the first device retrieved by 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, even when the logic and encryption algorithms were correct, there were occasional command failures. 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 a new connection; 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 an issue of background activity. After communicating with users, it was discovered that the user's Bluetooth switch was off, and this prompt appeared when entering the background. When turned on, this issue did not occur. This was because the disconnect method used the initialized CBCentralManager by default without checking if the Bluetooth switch was on.

Summary#

When interfacing with 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 Bluetooth device's service UUID and read/write UUID are provided, how to determine if Bluetooth is successfully connected, and the methods for command encryption and decryption. Then, use the system-provided methods to initialize Bluetooth and encapsulate the methods for handling Bluetooth operation commands and encryption/decryption methods. Finally, once everything is completed, remember to disconnect from the Bluetooth device.

References#

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