Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

iOS - BLE Scanning on background freezes randomly

UPDATE 14/08 - 3 - Found the real solution:

You can check out the solution in the answers below !

UPDATE 16/06 - 2 - May be the solution :

As Sandy Chapman suggested in comments in his answer, i'm able now to retrieve my peripheral at the start of a scan by using this method :

- (NSArray<CBPeripheral *> * nonnull)retrievePeripheralsWithIdentifiers:(NSArray<NSUUID *> * nonnull)identifiers

I'm actually trying to make it work by getting my peripheral back at the start of a scan and launching a connection (even if it's not in range) when i need it. iOS will keep it alive until it finds the device i'm looking for.

Please also note that there might be a bug in iOS 8.x that keep an app with debug build from scanning (disappearing callbacks as i get) from time to time if there is another app in background with release build that is using Bluetooth.

UPDATE 16/06 :

So i checked with retrievePeripheralsWithServices if any device was connected while i'm starting the scan. When i get the bug, i launch the app and the first thing i do in

- (void) applicationDidBecomeActive:(UIApplication *)application

is to check the size of the returned array. It's always 0, each time i get the bug. The bug can happen also if my device hasn't made any connection earlier in the current run. I'm also able to see my device advertising and trigger a command with a second device while i got the bug with another device.

UPDATE 10/06 :

  • I left my app running the entire night to check if there wasn't any memory leak or massive resource usage, here are my result after ~12-14 hours of running in the background. Memory/CPU usage are exactly the same as they were when i left. It leads me to think my app hasn't any leak that could lead iOS to close it to get back memory/CPU usage.

Resource usage analysis

UPDATE 08/06 :

  • Note that it's not an advertising issue since our BLE device is consistently powered, and we used the strongest BLE electronic card we could found.
  • It's also not an issue with iOS detection timing in background. I waited for a very long time(20~30min) to be sure that wasn't this issue.

ORIGINAL QUESTION

I'm currently working on an app that handle communication with a BLE device. One of my constraint is that i must connect to this device only when i have to send a command or read data. I must disconnect as soon as possible when it's done, to allow other potential users to do the same.

One of the features of the app is the following :

  • Users can enable an automatic command while the app is in the background. This automatic command triggers if the device hasn't been detected within 10 minutes.
  • My app scans until it finds my BLE device.
  • In order to keep it awake when i need it, i'm restarting the scan each time, because of the CBCentralManagerScanOptionAllowDuplicatesKey option ignorance.
  • When it's detected, i'm checking if the last detection was more than 10 minutes ago. If that's the case, i connect to the device and then write into the characteristic corresponding to the service i need.

The goal is to trigger this device when user come in range. It may happen a few minutes after being out of range such as a few hours, it depends on my users habits.

Everything is working fine this way, but sometimes (it seems like happening at random times), the scan sort of "freezes". My process is done well, but after a couple of time, i see my app scanning, but my didDiscoverPeripheral: call back is never called, even if my testing device is right in front of my BLE device. Sometimes it may take a while to detect it, but here, nothing happens after a couple of minutes.

I was thinking that iOS may have killed my app to claim back memory, but when i turn off and on Bluetooth, centralManagerDidUpdateState: is called the right way. If my app where killed, it shouldn't be the case right ? If i open my app, the scan is restarted and it's coming back to life. I also checked that iOS doesn't shutdown my app after 180 seconds of activity, but that's not the case because it's working well after this amount of time.

I've set up my .plist to have the right settings (bluetooth-central in UIBackgroundModes). My class managing all BLE processing is stored in my AppDelegate as a singleton accessible through all of my app. I've also tested to switch where i'm creating this object. Currently i'm creating it in the application:didFinishLaunchingWithOptions: method. I tried to put it in my AppDelegate init: but the scans fails every time while i'm in background if i do so.

I don't know which part of my code i could show you to help you better understand my process. Here is some samples that might help. Please note that " AT_appDelegate " is a maccro in order to access my AppDelegate.

// Init of my DeviceManager class that handles all BLE processing
- (id) init {
   self = [super init];

   // Flags creation
   self.autoConnectTriggered = NO;
   self.isDeviceReady = NO;
   self.connectionUncomplete = NO;
   self.currentCommand = NONE;
   self.currentCommand_index = 0;

   self.signalOkDetectionCount = 0; // Helps to find out if device is at a good range or too far
   self.connectionFailedCount = 0;  // Helps in a "try again" process if a command fails

   self.main_uuid = [CBUUID UUIDWithString:MAINSERVICE_UUID];
   self.peripheralsRetainer = [[NSMutableArray alloc] init];
   self.lastDeviceDetection = nil;

   // Ble items creation
   dispatch_queue_t queue = dispatch_queue_create("com.onset.corebluetooth.queue", DISPATCH_QUEUE_SERIAL);
   self.centralManager = [[CBCentralManager alloc] initWithDelegate:self queue:queue];

[self startScanning];
return self;
}

   // The way i start the scan
- (void) startScanning {

   if (!self.isScanning && self.centralManager.state == CBCentralManagerStatePoweredOn) {

    CLS_LOG(@"### Start scanning ###");
    self.isScanning = YES;

    NSDictionary *options = [NSDictionary dictionaryWithObject:[NSNumber numberWithBool:!self.isBackground] forKey:CBCentralManagerScanOptionAllowDuplicatesKey];
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{

    [self.centralManager scanForPeripheralsWithServices:@[self.main_uuid] options:options];

    });
  }
  }

  // The way i stop and restart the scan after i've found our device. Contains    some of foreground (UI update) process that you can ignore
  - (void) stopScanningAndRestart: (BOOL) restart {

  CLS_LOG(@"### Scanning terminated ###");
  if (self.isScanning) {

    self.isScanning = NO;
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{

    [self.centralManager stopScan];
  });

  // Avoid clearing the connection when waiting for notification (remote + learning)

     if (!self.isWaitingNotifiy && !self.isSynchronizing && self.currentCommand == NONE ) {
        // If no device found during scan, update view

        if (self.deviceToReach == nil && !self.isBackground) {

            // Check if any connected devices last
            if (![self isDeviceStillConnected]) {
               CLS_LOG(@"--- Device unreachable for view ---");

            } else {

                self.isDeviceInRange = YES;
                self.deviceToReach = AT_appDelegate.user.device.blePeripheral;
            }

            [self.delegate performSelectorOnMainThread:@selector(updateView) withObject:nil waitUntilDone:YES];       

        }

        // Reset var
        self.deviceToReach = nil;
        self.isDeviceInRange = NO;
        self.signalOkDetectionCount = 0;

        // Check if autotrigger needs to be done again - If time interval is higher enough,
        // reset autoConnectTriggered to NO. If user has been away for <AUTOTRIGGER_INTERVAL>
        // from the device, it will trigger again next time it will be detected.

        if ([[NSDate date] timeIntervalSinceReferenceDate] - [self.lastDeviceDetection timeIntervalSinceReferenceDate] > AUTOTRIGGER_INTERVAL) {

            CLS_LOG(@"### Auto trigger is enabled ###");
            self.autoConnectTriggered = NO;
        }
    }
}


   if (restart) {
    [self startScanning];
   }
  }

  // Here is my detection process, the flag "isInBackground" is set up each    time the app goes background
  - (void) centralManager:(CBCentralManager *)central didDiscoverPeripheral:(CBPeripheral *)peripheral advertisementData:(NSDictionary *)advertisementData RSSI:(NSNumber *)RSSI {

CLS_LOG(@"### : %@ -- %@", peripheral.name, RSSI);
BOOL deviceAlreadyShown = [AT_appDelegate isDeviceAvailable];

// If current device has no UUID set, check if peripheral is the right one
// with its name, containing his serial number (macaddress) returned by
// the server on remote adding

NSString *p1 = [[[peripheral.name stringByReplacingOccurrencesOfString:@":" withString:@""] stringByReplacingOccurrencesOfString:@"Extel " withString:@""] uppercaseString];

NSString *p2 = [AT_appDelegate.user.device.serial uppercaseString];

if ([p1 isEqualToString:p2]) {
    AT_appDelegate.user.device.scanUUID = peripheral.identifier;
}

// Filter peripheral connection with uuid
if ([AT_appDelegate.user.device.scanUUID isEqual:peripheral.identifier]) {
    if (([RSSI intValue] > REQUIRED_SIGNAL_STRENGTH && [RSSI intValue] < 0) || self.isBackground) {
        self.signalOkDetectionCount++;
        self.deviceToReach = peripheral;
        self.isDeviceInRange = (self.signalOkDetectionCount >= REQUIRED_SIGNAL_OK_DETECTIONS);

        [peripheral setDelegate:self];
        // Reset blePeripheral if daughter board has been switched and there were
        // not enough time for the software to notice connection has been lost.
        // If that was the case, the device.blePeripheral has not been reset to nil,
        // and might be different than the new peripheral (from the new daugtherboard)

       if (AT_appDelegate.user.device.blePeripheral != nil) {
            if (![AT_appDelegate.user.device.blePeripheral.name isEqualToString:peripheral.name]) {
                AT_appDelegate.user.device.blePeripheral = nil;
            }
        }

        if (self.lastDeviceDetection == nil ||
            ([[NSDate date] timeIntervalSinceReferenceDate] - [self.lastDeviceDetection timeIntervalSinceReferenceDate] > AUTOTRIGGER_INTERVAL)) {
            self.autoConnectTriggered = NO;
        }

        [peripheral readRSSI];
        AT_appDelegate.user.device.blePeripheral = peripheral;
        self.lastDeviceDetection = [NSDate date];

        if (AT_appDelegate.user.device.autoconnect) {
            if (!self.autoConnectTriggered && !self.autoTriggerConnectionLaunched) {
                CLS_LOG(@"--- Perform trigger ! ---");

                self.autoTriggerConnectionLaunched = YES;
                [self executeCommand:W_TRIGGER onDevice:AT_appDelegate.user.device]; // trigger !
                return;
            }
        }
    }

    if (deviceAlreadyShown) {
        [self.delegate performSelectorOnMainThread:@selector(updateView) withObject:nil waitUntilDone:YES];
    }
}

if (self.isBackground && AT_appDelegate.user.device.autoconnect) {
    CLS_LOG(@"### Relaunch scan ###");
    [self stopScanningAndRestart:YES];
}
  }
like image 294
Jissay Avatar asked Jun 06 '15 11:06

Jissay


2 Answers

In your sample code, it doesn't look like you're calling either of these methods on your CBCentralManager:

- (NSArray<CBPeripheral *> * nonnull)retrieveConnectedPeripheralsWithServices:(NSArray<CBUUID *> * nonnull)serviceUUIDs

- (NSArray<CBPeripheral *> * nonnull)retrievePeripheralsWithIdentifiers:(NSArray<NSUUID *> * nonnull)identifiers

It's possible that you're in a state where you're waiting on a didDiscoverPeripheral that will never come because the system is already connected. After instantiating your CBCentralManager, first check to see if your peripheral is already connected by calling one of these methods.

The start-up flow for my central manager which uses state restoration is as follows:

  1. Check for available peripherals from the restoration state (not needed in your case).
  2. Check for peripherals discovered from a previous scan (I keep these in an array of available peripherals).
  3. Check for connected peripherals (using methods mentioned above).
  4. Begin scanning if none of the above returned a peripheral.

One additional thing tip that you may have already discovered: iOS will cache the characteristics and UUIDs advertised by a peripheral. If these change, the only way to clear the cache is to toggle bluetooth off and on in the iOS system settings.

like image 113
Sandy Chapman Avatar answered Nov 05 '22 19:11

Sandy Chapman


After many many tries, i may finally have found the real solution.

FYI, i had many talks with an engineer at Apple (via Technical Support). He led me in two ways :

  • Checking that Core Bluetooth preservation / restoration state are correctly implemented. You will be able to found a few threads about it on stack overflow like this thread or this one. You will also found apple documentation useful here : Core Bluetooth Background Processing.
  • Checking connections parameters implemented in my Bluetooth device, such as : Connection Interval, Minimum and Maximum connection interval, Slave latency and Connection timeout. There is much information in Apple Bluetooth Design Guidelines

The real solution may take part in these elements. I added and corrected things while i was searching to clean this bug. But the thing that made it work (i tested it for around 4-5h and it didn't froze at all) was about dispatch_queue.

What i did before :

// Initialisation
dispatch_queue_t queue = dispatch_queue_create("com.onset.corebluetooth.queue", DISPATCH_QUEUE_SERIAL);
self.centralManager = [[CBCentralManager alloc] initWithDelegate:self queue:queue options:@{CBCentralManagerOptionRestoreIdentifierKey:RESTORE_KEY}];

// Start scanning
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
   [self.centralManager scanForPeripheralsWithServices:@[self.main_uuid] options:options];
});

// Stop scanning
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
   [self.centralManager stopScan];
});

What i'm doing now :

// Initialisation
self.bluetoothQueue = dispatch_queue_create("com.onset.corebluetooth.queue", DISPATCH_QUEUE_SERIAL);
    self.centralManager = [[CBCentralManager alloc] initWithDelegate:self queue:self.bluetoothQueue options:@{CBCentralManagerOptionRestoreIdentifierKey:RESTORE_KEY}];

// Start scanning
dispatch_async(self.bluetoothQueue, ^{
   [self.centralManager scanForPeripheralsWithServices:@[self.main_uuid] options:options];
});

// Stop scanning
dispatch_async(self.bluetoothQueue, ^{
   [self.centralManager stopScan];
});

Please note that i added this line to my DeviceManager.h (my main Ble class) :

@property (atomic, strong) dispatch_queue_t bluetoothQueue;

As you can see it was a little messed up :)

So now i'm able to scan as long as needed. Thanks for your help ! I hope it might help someone one day.

like image 39
Jissay Avatar answered Nov 05 '22 20:11

Jissay