steamclock / bluejay

A simple Swift framework for building reliable Bluetooth LE apps.
MIT License
1.09k stars 98 forks source link

How to scan in the background as a response to a significant location change? #234

Open sarbogast opened 4 years ago

sarbogast commented 4 years ago

I see a lot of examples online and in your documentation showing how to scan either in a one-off in the foreground, or in the background using background restoration. I'm using both of these possibilities, but continuous scanning in the background only reports new peripherals, and I would like to be able to trigger a scan when I detect a significant location change. So I register for significant location changes, I receive these events, and then I use the function below to scan for BLE peripherals:

func scanForLocation(_ location: CLLocation) {
        log.debug("Scanning for bluetooth tags for location [\(location.coordinate.latitude),\(location.coordinate.longitude)]...", context: "AppDelegate.scanForLocation(_:)")

        let scanner = Bluejay()
        scanners.append(scanner)
        scanner.start()
        scanner.scan(
                duration: 10.0,
                allowDuplicates: false,
                throttleRSSIDelta: 5,
                serviceIdentifiers: self.scannedServices,
                discovery: { (discovery, discoveries) -> ScanAction in
                    log.debug("Discovered \(discovery.peripheralIdentifier.description) with advertisement packet \(discovery.advertisementPacket) and RSSI \(discovery.rssi)")
                    log.debug("\(discoveries.count) devices discovered so far.")
                    return .continue
                }, expired: { (discovery, discoveries) -> ScanAction in
            log.debug("\(discovery.peripheralIdentifier.description) disappeared")
            log.debug("\(discoveries.count) devices remaining.")
            return .continue
        }) { (discoveries, error) in
            log.debug("Scan completed for location [\(location.coordinate.latitude),\(location.coordinate.longitude)]. \(discoveries.count) tags discovered", context: "AppDelegate.scanForLocation(_:)")
            if let error = error {
                log.error("An error occurred while scanning on location: \(error.localizedDescription)", context: "AppDelegate.configureLocation()")
            } else {
                self.sendTagSeenReport(location: location, tags: discoveries)
            }
            self.scanners.removeAll { bluejay -> Bool in
                bluejay.uuid == scanner.uuid
            }
        }
    }

But I never get my peripheral detected this way, even though I can scan it manually when the app is in the foreground with exactly the same function. Is it because of my scanning parameters, or is it because of OS limitations? Is it even possible to do what I'm trying to do, tracking the movement of a Bluetooth tag based on the movement of the smartphone close to it?

sakuraehikaru commented 4 years ago

Hi @sarbogast, based on Apple's documentation, background scanning is a bit more limited but still possible:

First, do you have this capability enabled?

Screen Shot 2020-03-10 at 10 30 33 AM

On the central side, foreground-only apps—apps that have not declared to support either of the Core Bluetooth background execution modes—cannot scan for and discover advertising peripherals while in the background or while suspended.

Second,

  • The CBCentralManagerScanOptionAllowDuplicatesKey scan option key is ignored, and multiple discoveries of an advertising peripheral are coalesced into a single discovery event.
  • If all apps that are scanning for peripherals are in the background, the interval at which your central device scans for advertising packets increases. As a result, it may take longer to discover an advertising peripheral.
sarbogast commented 4 years ago

Yes, I have the "Uses Bluetooth LE accessories" option checked. About your second point, does that mean that even if I launch several scanning sessions in the background, with several instances of Bluejay (and so several instances of CBCentralManager I presume), each device will be seen only once? Because if that's the case, that would make it impossible for me to track the movement of my peripherals in the background. About the delay, I tried to increase the duration of the scan to 30 seconds, and I still got no discovery.

sakuraehikaru commented 4 years ago

I made some tweaks to the Bluejay and Dittojay heart sensor demo apps to try out repeated background scanning, and I was able to scan and to detect every time I background the app, so we know it should work at least in a simple case. That said...

Are you stopping and restarting the scan? Seems like you are, but just wanted to double check. Based on an Apple developer's response, each new scan in the background should still give you 1 discovery if the device is available.

Also, are you sure the scan is queued at the top and running when the app is backgrounded?

While you can have multiple Bluejay instances, I'm not sure if that's needed nor recommended, but I also don't know too much about your app. What is the purpose of having an array of Bluejay instances?

Once you've picked up a new device from a previous scan, you can also try background connect/re-connect as a way to detect a previous device coming back within range.

In general, BLE on iOS is mostly designed to work with 1 device at a time, hence the various foreground and background scan limitations imposed by Apple. Keep in mind there will be things that might not be possible, though I'm also not saying that's actually the case here.

Finally, are you also doing something else in the background that could interfere with the scan? For example, maybe the device is already connected? Also, your code example in this issue has the same problem as https://github.com/steamclock/bluejay/issues/233 with the Bluejay instance being deallocated right after the scan function exits. Maybe you've also fixed that, but needed to confirm.

sarbogast commented 4 years ago

Are you stopping and restarting the scan? Seems like you are, but just wanted to double check. Based on an Apple developer's response, each new scan in the background should still give you 1 discovery if the device is available.

I'm scanning for a certain amount of time each time because I don't want the location-based scan to run indefinitely, as the purpose here is to scan for all the peripherals visible while the phone is in a given location. If the phone is moving very fast, say driving on the highway, then if I scan for too long, the correlation between the location detected by the significant location change and the bluetooth tag will not really be relevant. So each time I detect a significant location change, say each time the phone has moved by about 500m, I start a new scan for a few seconds, and all the visible peripherals discovered are assumed to be roughly at the detected location. Or at least that's the goal. And then we can do heuristics in the backend to integrate all those reports and make sense of them.

Also, are you sure the scan is queued at the top and running when the app is backgrounded?

What do you mean by that exactly?

While you can have multiple Bluejay instances, I'm not sure if that's needed nor recommended, but I also don't know too much about your app. What is the purpose of having an array of Bluejay instances?

The array of Bluejay instance is here to solve the instance allocation problem (#233): each time I detect a significant location change, I create a new Bluejay instance and store it in a member array in AppDelegate to keep a reference on it so that it's not deallocated until the scan is over. When the scan times out, I remove it from the array so that it can safely be deallocated.

Once you've picked up a new device from a previous scan, you can also try background connect/re-connect as a way to detect a previous device coming back within range.

But I can only connect to a limited number of peripherals, right? If that's the case, this solution would be limited. It's a lead though if the scanning itself doesn't work.

In general, BLE on iOS is mostly designed to work with 1 device at a time, hence the various foreground and background scan limitations imposed by Apple. Keep in mind there will be things that might not be possible, though I'm also not saying that's actually the case here.

I guess, and it seems like every version of iOS makes it more restrictive, which is why I'm trying to get expertise from people who do a lot of BLE because those limitations are not always well documented, and yet I have to explain them to my client.

Finally, are you also doing something else in the background that could interfere with the scan? For example, maybe the device is already connected? Also, your code example in this issue has the same problem as #233 with the Bluejay instance being deallocated right after the scan function exits. Maybe you've also fixed that, but needed to confirm.

For now, I am not using BLE connection at all. The peripherals I am trying to scan are BLE tags that we designed ourselves, with our own advertised service UUIDs, and they are not designed to be connected to, only scanned.

sakuraehikaru commented 4 years ago

I think the array of Bluejay instances could be the problem here:

  1. You don't need an array of Bluejay instances to accomplish what you've described here, and you don't need it to solve a strong reference issue
  2. You could be allocating too many Bluejay, and therefore, CoreBluetooth sessions, burdening the system unnecessarily. For example, I am not sure if your Bluejay instances in the array are actually being stopped and deallocated as expected.

I would recommend using just 1 Bluejay instance stored in the AppDelegate for example, and try a basic background scanning setup. It worked for me and I was able to detect peripherals when the app is backgrounded.

sarbogast commented 4 years ago

I switched to using just one instance that I initialize and start in didFinishLaunchingWithOptions, but I still get 0 peripherals discovered when in the background:

let ForegroundScanDuration = 5.0
let BackgroundScanDuration = 60.0

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    //Bluetooth stuff
    let bleScanner = Bluejay()
    let locationScanner = Bluejay()
    let scannedServices = HTSettings.scannedBluetoothServices?.values.map({ uuid -> ServiceIdentifier in
        ServiceIdentifier(uuid: uuid)
    })
    var locationRequest: LocationRequest?
    let restorationIdentifier = Bundle.main.bundleIdentifier!

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        ...
        configureBluetooth(launchOptions: launchOptions)
        configureLocation()
        return true
    }

    func configureLocation() {
        if let locationRequest = locationRequest {
            log.debug("Stopping previous location request before starting a new one.", context: "AppDelegate.configureLocation()")
            locationRequest.stop()
        }
        log.info("Configuring significant location change tracker...")
        LocationManager.shared.backgroundLocationUpdates = true
        locationRequest = LocationManager.shared.locateFromGPS(.significant, accuracy: .any, distance: CLLocationDistance(exactly: 500), activity: .other, timeout: nil) { locationResult in
            switch locationResult {
            case .success(let location):
                log.debug("Significant location change recorded. New location: (\(location.coordinate.latitude), \(location.coordinate.longitude))", context: "AppDelegate.configureLocation")

                self.scanForLocation(location, background: UIApplication.shared.applicationState != .active)

                break
            case .failure(let error):
                log.debug("Could not locate you because: \(String(describing: error))", context: "AppDelegate.configureLocation()")
            }
        }
    }

    func scanForLocation(_ location: CLLocation, background: Bool) {
        log.debug("[\(background ? "BG" : "FG")] Scanning for bluetooth tags for location [\(location.coordinate.latitude),\(location.coordinate.longitude)]...", context: "AppDelegate.scanForLocation(_:)")

        if(!locationScanner.isScanning) {
            log.debug("Scan starting with scanner \(locationScanner.uuid.uuidString)", context: "AppDelegate.scanForLocation(_:background:)")
            locationScanner.scan(
                duration: background ? BackgroundScanDuration : ForegroundScanDuration,
                    allowDuplicates: !background,
                    throttleRSSIDelta: 5,
                    serviceIdentifiers: self.scannedServices,
                    discovery: { (discovery, discoveries) -> ScanAction in
                        log.debug("[\(background ? "BG" : "FG")] Scanner \(self.locationScanner.uuid.uuidString)) discovered \(discovery.peripheralIdentifier.description) with advertisement packet \(discovery.advertisementPacket) and RSSI \(discovery.rssi)")
                        log.debug("[\(background ? "BG" : "FG")] \(discoveries.count) devices discovered so far.")
                        return .continue
                    }, expired: { (discovery, discoveries) -> ScanAction in
                log.debug("[\(background ? "BG" : "FG")] \(discovery.peripheralIdentifier.description) disappeared")
                log.debug("[\(background ? "BG" : "FG")] \(discoveries.count) devices remaining.")
                return .continue
            }) { (discoveries, error) in
                log.debug("[\(background ? "BG" : "FG")] Scan with scanner \(self.locationScanner.uuid.uuidString) completed for location [\(location.coordinate.latitude),\(location.coordinate.longitude)]. \(discoveries.count) tags discovered", context: "AppDelegate.scanForLocation(_:)")
                if let error = error {
                    log.error("[\(background ? "BG" : "FG")] An error occurred while scanning on location: \(error.localizedDescription)", context: "AppDelegate.configureLocation()")
                } else {
                    self.sendTagSeenReport(location: location, tags: discoveries, background: background)
                }
            }
        } else {
            log.debug("Scan cancelled because there was already a scan in progress", context: "AppDelegate.scanForLocation(_:background:)")
        }
    }

    func configureBluetooth(launchOptions: [UIApplication.LaunchOptionsKey: Any]?) {
        locationScanner.start()

        let backgroundRestoreConfig = BackgroundRestoreConfig(
                restoreIdentifier: restorationIdentifier,
                backgroundRestorer: self,
                listenRestorer: self,
                launchOptions: launchOptions)

        let backgroundRestoreMode = BackgroundRestoreMode.enable(backgroundRestoreConfig)

        let options = StartOptions(
                enableBluetoothAlert: true,
                backgroundRestore: backgroundRestoreMode)
        bleScanner.start(mode: .new(options))
        bleScanner.register(connectionObserver: self)

        bleScanner.scan(serviceIdentifiers: scannedServices, discovery: { (discovery, discoveries) -> ScanAction in
            log.debug("\(discovery.peripheralIdentifier.description) discovered with data \(discovery.advertisementPacket)", context: "AppDelegate.configureBluetooth()")
            log.debug("Locating user...", context: "AppDelegate.configureBluetooth()")
            LocationManager.shared.locateFromGPS(.oneShot, accuracy: .any, distance: nil, activity: .other, timeout: nil) { result in
                switch result {
                case .failure(let error):
                    log.error("Error locating user after discovering \(discovery.peripheralIdentifier.description): \(String(describing: error))")
                    break
                case .success(let location):
                    log.debug("Successfully located user in [\(location.coordinate.latitude), \(location.coordinate.longitude)] after discovering \(discovery.peripheralIdentifier.description)")
                    self.sendTagSeenReport(location: location, tags: [discovery], background: UIApplication.shared.applicationState != .active)
                    break
                }
            }
            return .continue
        }) { (discoveries, error) in
            if let error = error {
                log.error("Error scanning in the background: \(String(describing: error))", context: "AppDelegate.configureBluetooth()")
            } else {
                log.debug("Stopped scanning in the background for some reason. Locating user...", context: "AppDelegate.configureBluetooth()")
                LocationManager.shared.locateFromGPS(.oneShot, accuracy: .any, distance: nil, activity: .other, timeout: .delayed(3)) { result in
                    switch result {
                    case .failure(let error):
                        log.error("Error locating user after scanning stopped (\(discoveries.count) devices discovered): \(String(describing: error))", context: "AppDelegate.configureBluetooth()")
                        break
                    case .success(let location):
                        log.debug("Successfully located user in [\(location.coordinate.latitude), \(location.coordinate.longitude)] after discovering \(discoveries.count) tags", context: "AppDelegate.configureBluetooth()")
                        self.sendTagSeenReport(location: location, tags: discoveries, background: UIApplication.shared.applicationState != .active)
                        break
                    }
                }
            }
        }
    }

    private func sendTagSeenReport(location: CLLocation, tags: [ScanDiscovery], background: Bool) {
        ...
    }

    func quickFrontScan() {
        if UIApplication.shared.applicationState != .active {
            return
        }
        log.debug("Performing manual scan in the foreground. Locating user...")
        LocationManager.shared.locateFromGPS(.oneShot, accuracy: .block) { result in
            switch result {
            case .failure(let error):
                log.error("Error locating user: \(String(describing: error))", context: "AppDelegate.quickFrontScan()")
            case .success(let location):
                log.debug("User located in [\(location.coordinate.latitude), \(location.coordinate.longitude)]", context: "AppDelegate.quickFrontScan()")
                self.scanForLocation(location, background: false)
            }
        }
    }

    func applicationDidEnterBackground(_ application: UIApplication) {
        // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
        // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
        log.info("App did enter background")

        LocationManager.shared.locateFromGPS(.oneShot, accuracy: .any) { result in
            switch result {
            case .failure(let error):
                log.error("Error locating user in the background: \(String(describing: error))", context: "AppDelegate.applicationDidEnterBackground(_:)")
            case .success(let location):
                log.debug("User located in the background in [\(location.coordinate.latitude), \(location.coordinate.longitude)]", context: "AppDelegate.applicationDidEnterBackground(_:)")
                self.scanForLocation(location, background: true)
            }
        }
    }

    func applicationDidBecomeActive(_ application: UIApplication) {
        // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
        log.info("App did become active")
        self.quickFrontScan()
    }
}
sakuraehikaru commented 4 years ago

Hi @sarbogast, I'm really sorry but I don't have other suggestions at this point. From my simple testing, I am able to scan and detect devices in the background.

Your use case is a bit more involved than my test project, and without being able to actually test with the custom peripherals you have, I can't offer any other solutions at this point.

sakuraehikaru commented 4 years ago

It also sounds like scanning in the background is working, but you just aren't getting any discoveries. There might still be something in your app or in your custom peripheral that's causing this. You also still have two Bluejay instances.

Perhaps for an experiment, try to isolate the problems by removing complexity:

Then if that works, slowly re-introduce the complex features in a step-by-step basis back to your app.