chipweinberger / flutter_blue_plus

Flutter plugin for connecting and communicationg with Bluetooth Low Energy devices, on Android, iOS, macOS
Other
767 stars 464 forks source link

[Help]: strange behaviour when using removeIfGone in combination with navigation #689

Closed iKK001 closed 11 months ago

iKK001 commented 11 months ago

Requirements

Have you checked this problem on the example app?

No

FlutterBluePlus Version

1.28.7

Flutter Version

3.13.9

What OS?

iOS

OS Version

17.1.1

Bluetooth Module

ESP32-S3 Wroom 1U

What is your problem?

On a first Flutter Screen I start scanning BLE devices.

The app does this in order to show present BLE-devices of known advNames.

Everything works - I can power-On devices and they get recognized from the App- and I can power-off devices and they dissappear form the FlutterBluePlus.scanResults list as expected. Everything seems to work inside my parent-Screen.

Note that I use removeIfGone: const Duration(seconds: 5), when calling startScan (to be able to detect the dissapearing of devices). So far so good.

await FlutterBluePlus.startScan(
      timeout: const Duration(seconds: constants.BLUETOOTH_SCAN_DURATION),
      continuousUpdates: true,
      removeIfGone: const Duration(seconds: 5),
      continuousDivisor: divisor,
);

This removeIfGone is important since when setting removeIfGone to null, then I do NOT get the error.

Here the error behaviour explained:

From this first parent-Screen, I can click on one device to navigate to a second Detail-screen where I would like to show advertisement-status again for this particular device.

I use Navigator.pushNamed(...) to do the navigation !

--> on this second Screen, the FlutterBluePlus.scanResults-list suddenly does no longer carry my device (with known advName). This loss of my device inside this list happens precisely after the removeIfGone-Duration has passed !!!

The way I detect this problem in the detail-Screen is as follows:

class DetailScreen extends State<DetailScreen> {
  late StreamSubscription<List<ScanResult>> _scanResultsSubscription;

 @override
  void initState() {

    _scanResultsSubscription = FlutterBluePlus.scanResults.listen((results) {
      bool hasMyDevice = results
          .where((d) => d.device.advName == "XXXXXXXX")
          .isNotEmpty;
      if (hasMyDevice) {
        print("yes");
      } else {
        print("no");
      }
    }, onError: (e) {
      debugPrint("ScanResult error: $e");
    });

    super.initState();
  }
}

==> entering detail-Screen: the log prints "yes", "yes", "yes".... ==> but precisely after removeIfGone-Duration has passed since Scan-start in parent-Screen then I get logs "no", "no", "no"....

Why this strange behaviour ???

Further observations:

If I do the very same code inside the initState of the parent-Screen, then I never get this device-loss in the result-List. (i.e. it prints "yes", "yes" "yes" all the time - even after removeIfGone-Duration has passed.). EXCEPT if I navigate to the detail-screen!!!

The problem only happens if I do this hasMyDevice-test after Navigation to the detail-screen. And it happens no matter where I log (i.e. inside parent- or inside detail-Screen, both turn to "no").

Only AFTER navigation to detail-screen, the hasMyDevice check turns to "no", precisely after the removeIfGone-Duration, no matter on which screen I test it.

If I remain on the parent-Screen, I never get the issue!

==> And now the strangest observation:

If I reset the physical device being on detail-Screen. Then the hasMyDevice-check gives back "yes" again forever (or of course also if I turn off the physical device - but this is not the point here).

The point is that this FlutterBluePlus.scanResults.listen((results) { ... } routine somehow looses my known-device from its list after the navigation. And it finds it again, when power-off/power-on the physical BLE-device.

There must be a problem with this removeIfGone-duration mechano in your libary. Or maybe with the advName-caching - I have no idea.

Very important: WHEN I GET THE myKnown-device LOSS - ALL OTHER DEVICES STILL ARE SHON IN THE scanResults.listener. ONLY THE DEVICE I WAS CHECKING ITS PRESENCE DISSAPPEARS !!!!

(i.e. only the one device is "lost" from the scanResults-list, only the one I use the routine to get this hasMyDevice information:

bool hasMyDevice = results
      .where((d) => d.device.advName == widget.myKnownId)
      .isNotEmpty;

What is wrong with this hasMyDevice check ??? And why does it only affect if I navigate to the detail-Screen.

I am completely lost with this.

Any idea why ????

Logs

before navigation on parent-Screen:
-----------------------------------

"yes", "yes", "yes", "yes", "yes", "yes" etc.

after navigation to detail-Screen:
----------------------------------

--> tested on detail-Screen: 
"yes", "yes", "yes", ....
...after removeIfGone-Duration has passed:
"no", "no", "no", "no", ...
...if I reboot physical device:
"yes", "yes", "yes", ....

--> tested on parent-Screen (after navigation)
"yes", "yes", "yes", ....
...after removeIfGone-Duration has passed:
"no", "no", "no", "no", ...
...if I reboot physical device:
"yes", "yes", "yes", ....

P.S. The number of "yes" or "no"'s of course depends on the number of other BLE-devices in the room. I expect "yes" all the time for my knownId-device.

P.P.S. The problem also exists if I do a showModalBottomSheet naviagtion instead of a Navigator.pushNamed(...)....

iKK001 commented 11 months ago

I finally found a solution !

I guess my knowledge of dart Streams was limited. After reading a bit into the topic, I found the following solution:

Instead of trying to get your device out of the listener FlutterBluePlus.scanResults.listen((results) { ... } (inside the initState), a much better approach is to work with a StreamBuilder directly in you build() method :

@override
  Widget build(BuildContext context) {
    return StreamBuilder<List<ScanResult>>(
      stream: FlutterBluePlus.scanResults,
      builder: (_, snapshot) {
        if (snapshot.hasData) {
          final filteredResult = snapshot.data!.where((element) => element.device.advName == widget.myKnownId);
          return Container( ...whatever you want to do with filteredResult...);
        } else {
          return const Center(
            child: CircularProgressIndicator(),
          );
        }
      },
    );
  }

This way, I can work with live-changes on filteredResultas long as Scan is happening.

This works in detail-Screen even tough the scan has been started in parent-screen.

Thank good I understand Streams a bit better now ;)

iKK001 commented 11 months ago

I was too early with my joy ! The same thing happened again: after removeIfGone-Duration has passed, then the filteredResult gets empty.

I am totally sure that it worked two times after the refactor. And now I am back to the misery again. No idea why ?

I am on the simulator running on an actual device.

This thing is turning me crazy !!!!!!!!

Any idea why this happens ?

chipweinberger commented 11 months ago

when stopScan is called (or scan timeout) the scan results are cleared. if you re-listen after stopscan, it will be empty.

if you dont want that behavior, you should put the results in your own cache somewhere. use a global variable. then just always use your cache.

iKK001 commented 11 months ago

I think you misunderstood the case. Please read carefully:

I do not call stopScan anywhere in my app.

The device dissappears after removeIfGone-duration - and NOT after timeout. The timeout is not reached yet !!!!!!!!!!!!!

AND in addition, only the one particular device dissappears from the scanResults-list that I test with .where((element) => element.device.advName == widget.myKnownId); !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

I do use startScan with the removeIfGone like so (inside the parent-Screen)

await FlutterBluePlus.startScan(
    timeout: const Duration(seconds: 180), 
    continuousUpdates: true,
    removeIfGone: const Duration(seconds: 5), // after this time, the device dissaspears from scanResults-list
    continuousDivisor: divisor,
);

Then I navigate to the detail-Screen after let's say one or two seconds.

After 5 seconds from start, the device is gone from the list - even tough timeout is not reached.

But why does ONLY ONE DEVICE DISSAPPEAR AFTER THIS removeIfGone duration ??????? The rest of the devices still are available in the list after the removeIfGone-duration has passed. ONLY the one particular device I apply the .where dissappears from the scanResults-list. AND it only does so if I apply the .where filter inside a detail-screen.

And again, it does not dissappear after the timeout - it dissappears after the removeIfGone-duration !

It reappears only if I reboot the physical device.

Therefore your library does not re-start the advertisement reading for this particular device with the .where filter.

And since timeout is not reached yet, the removal from the list should not take place at all.

chipweinberger commented 11 months ago

It reappears only if I reboot the physical device.

It sounds like your device is misbehaving and has stopped advertising. Can other BLE scanner apps find it when this happens?

iKK001 commented 11 months ago

yes they can find it no problem. It has nothing to do with the actual device.

In the meantime I found out that if I do the following, then I can at least get the detail-screen working:

--> If I completely eliminate any FlutterBluePlus.onScanResults.listen((results) { ... } activity on the parent-Screen, then it works !! i.e. then the detail-screen keeps the myKnownID-device in the list even after the removeIfGone-duration.

So it seems that if there are two .onScanResults-listeners (or StreamBuilders) at work, (one in the parent-Screen and one in the detail-Screen), the the problem occurs !

I thought that one could listen to the .onScanResults anywhere and in as many screens as needed (since it is a stream that never closes).

But it seems that I need to cancel the parent-listener maybe at the moment of navigation to the detail-screen ??

Again, it is ultra-strange that only the knownDeviceId gets missing after the removeIfGone-duration on which the parent-screen applies the .where filter inside the listener (or StreamBuilder).

iKK001 commented 11 months ago

hhhmmmm, I think you still have a point with the device as being the issue: indeed, if I use another bluetooth-scanner I can see that the device also dissappears from the list of found devices. Precisely after this removeIfGone-duration. And only my device with knownId.

Only if the parent-screen and the detail-screen both do their .onScanResults-listener with the .where filter, this happens. Not otherwise.

Could two listeners somehow not obey some BLE rules ?

Maybe there is another way I can observe my device inside parent- and detail-screen each ?? (i.e. other than using two .onScanResults-listeners or StreamBuilders ?

In the meantime I try my idea to cancel the parent-listner when navigating and vice-versa.

iKK001 commented 11 months ago

fyi: scanResultsSubscription.cancel() of the parent-listener at the moment of navigation does not help, unfortunately !

chipweinberger commented 11 months ago

I thought that one could listen to the .onScanResults anywhere and in as many screens as needed (since it is a stream that never closes)

yes that is how it should work!

hhhmmmm, I think you still have a point with the device as being the issue: indeed, if I use another bluetooth-scanner

yes i still think device issue. I think removeIfGone is a coincidence.

what happens if you make the removeIfGone duration larger? like 60s? does it go away after 60s?

iKK001 commented 11 months ago

nope - then the error happens after 60s.

It is cleartly entirely bound to this removeIfGone delay !

In the meantime I rewrote the entire parent- and detail-listeners to be inside a Riverpod StreamProvider.

Same story: BLE-device gets kicked out of scanResults-list !!!!!!!

I can only get it to work if I stop calling the listener inside the parent-screen. Then the detail-screen keeps the device inside the scanResults-list. Otherwise, as soon as there is a parent-listener as well, then the two listener bite each other.

I am not sure what happens in case of Riverpod. Since I assumed that there, I would have at least only one instance of the scanResutl-list for the entire App. So I need to keep investigating what is going on.

Fact is that the parent-listener bothers the detail-listener !

For a reference, here is the entrire Riverpod code:

Note:

The important method is:

AsyncValue<ScanResult> getScanResultOfId(String deviceId) {
    return state.whenData((results) {
      return results.firstWhere((result) => result.device.advName == deviceId);
    });
  }
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:myApp_flutter/bluetooth/extra.dart';
import 'package:myApp_flutter/constants/constants.dart' as constants;

part 'bluetooth_provider.g.dart';

@riverpod
class MyAppBLE extends _$MyAppBLE {
  StreamSubscription<BluetoothAdapterState>? adapterStateStateSubscription;
  StreamSubscription<bool>? isScanningSubscription;
  StreamSubscription<List<ScanResult>>? scanResultsSubscription;
  bool isScanning = false;
  BluetoothAdapterState adapterState = BluetoothAdapterState.unknown;

  BluetoothDevice? myAppDevice;
  List<BluetoothService> _services = [];

  @override
  Stream<List<ScanResult>> build() async* {
    yield [];
  }

  Future<void> startScan() async {
    try {
      // android is slow when asking for all advertisments,
      // so instead we only ask for 1/8 of them
      int divisor = Platform.isAndroid ? 8 : 1;
      await FlutterBluePlus.startScan(
        timeout: const Duration(seconds: constants.BLUETOOTH_SCAN_DURATION),
        continuousUpdates: true,
        removeIfGone: const Duration(seconds: 5),
        continuousDivisor: divisor,
      );
    } catch (e) {
      debugPrint("Start Scan Error: $e");
    }
  }

  void stopScan() {
    FlutterBluePlus.stopScan();
  }

  void listenForScanningState() =>
      isScanningSubscription = FlutterBluePlus.isScanning.listen(
        (scanningState) {
          isScanning = scanningState;
        },
      );

  void listenForAdapterState() =>
      adapterStateStateSubscription = FlutterBluePlus.adapterState.listen(
        (adaState) {
          adapterState = adaState;
        },
      );

  void listenForScanResults() =>
      scanResultsSubscription = FlutterBluePlus.onScanResults.listen(
        (results) {
          state = AsyncData(results);
        },
      );

  void cancelAdapterStateSubscription() =>
      adapterStateStateSubscription?.cancel();

  void cancelScanResultsSubscription() => scanResultsSubscription?.cancel();

  void cancelScanningStateSubscription() => isScanningSubscription?.cancel();

  AsyncValue<ScanResult> getScanResultOfId(String deviceId) {
    return state.whenData((results) {
      return results.firstWhere((result) => result.device.advName == deviceId);
    });
  }

  void connectDevice(BluetoothDevice device) {
    device.connectAndUpdateStream().catchError((e) {
      debugPrint("Error connecting: $e");
    });
  }

  void disconnectDevice() {
    myAppDevice?.disconnect();
  }

  Future<BluetoothCharacteristic?> treatServices(
      String serviceUuid, String characteristicUuid) async {
    try {
      if (myAppDevice == null) {
        debugPrint("No BLE device connected.");
        return null;
      } else {
        // Waiting for BLE device to finish connecting...
        int bailOutCounter = 0;
        while (myAppDevice?.isConnected == false && bailOutCounter < 10) {
          await Future.delayed(const Duration(milliseconds: 300));
          bailOutCounter++;
        }
        // if connected
        debugPrint("BLE device Connected");
        if (myAppDevice?.isConnected ?? false) {
          _services = await myAppDevice!.discoverServices();
          Iterable<BluetoothService> myAppService =
              _services.where((s) => s.uuid == Guid(serviceUuid));
          if (myAppService.isNotEmpty) {
            Iterable<BluetoothCharacteristic> myAppCharacteristic = myAppService
                .first.characteristics
                .where((c) => c.uuid == Guid(characteristicUuid));
            if (myAppCharacteristic.isNotEmpty) {
              return myAppCharacteristic.first;
            }
          }
        }
      }
      return null;
    } catch (e) {
      debugPrint("Discover Services Error: $e");
      return null;
    }
  }
}

Here is the parent-Screen

class MyAppParent extends ConsumerStatefulWidget {
  const MyAppParent({
    super.key,
    required this.items,
  });

  final List<MyList> items;

  @override
  ConsumerState<MyAppParent> createState() => _MyAppParentBluetoothState();
}

class _MyAppParentBluetoothState extends ConsumerState<MyAppParent> {

  @override
  void initState() {
    ref.read(myAppBLEProvider.notifier).listenForAdapterState();
    ref.read(myAppBLEProvider.notifier).listenForScanResults();
    ref.read(myAppBLEProvider.notifier).listenForScanningState();

    super.initState();
  }

  @override
   Widget build(BuildContext context) {
      final blues = ref.watch(myAppBLEProvider);
     return GridView.builder(
      itemCount: ref.read(someOtherProvider.notifier).numberOfProducts(),
      shrinkWrap: true,
      physics: const ScrollPhysics(),
      gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
        childAspectRatio: childAspectRatio,
        crossAxisCount: crossAxisCount,
        mainAxisSpacing: 20.h,
        crossAxisSpacing: 20.w,
      ),
      itemBuilder: (context, index) {
        if (widget.items.isEmpty) {
          return const Center(
            child: Text("No Products found"),
          );
        } else {
          return ref
              .read(myAppBLEProvider.notifier)
              .getScanResultOfId(widget.items[index].id)
              .when(
                data: (item) =>
                    // in cases of item != null, means scanResult is available
                    _gridTile(
                  _showLoading,
                  widget.items,
                  index,
                  item,
                ),
                loading: () => const Center(child: CircularProgressIndicator()),
                error: (e, st) =>
                    // in case of error, means no scanResult yet...
                    _gridTile(
                  _showLoading,
                  widget.items,
                  index,
                  null,
                ),
              );
        }
      },
    );
  }
}
iKK001 commented 11 months ago

question: What about when the device is connected - does the scanResults list then kick out the device ??

I have a semi-working solution now (improving the Riverpod Provider). However as soon as the device connects, then the scanResults list kicks out the device precisely after the removeIfGone duration.

chipweinberger commented 11 months ago

this is because your device stops advertising when connected

this is in the readme!

chipweinberger commented 11 months ago

btw, you dont have to use removeIf gone.

you can reimplement it yourself using your own stream controllers, etc. see the fbp code for inspiration.

iKK001 commented 11 months ago

Before all, I would like to thank you for your support. And also for keeping up this great BLE library.

And I also appreciate very much your effort to keep up the readme. (...that I should have read more thoroughly ;)).

I have a working solution now !

Here a summary of the mistakes I did and the misleadings I followed for too long:

  1. Indeed I did have a connect in my detail-screen (that sat there without me recognizing this fact - I did it too fast I guess).. This connect made the scanResult-list loose the known-device for a very long debug-time.

1b. I have to say, it still appears very very strange to me that the dissappearing of this known-device after connect happens precisely after the removeIfGone-duration :/ (but this must be a funny coincidence I guess). The coincidence was reason for me to hunt for the ghost that does not exist.

  1. A second mistake I did was to carry parent-screen scanResult-references all the way to the detail-screen. A much better approach is to use State-handling (Riverpod in my case) to make a single source of truth for the parent- and the detail-screen.

2b. A helpful method inside my new BLE Riverpod StreamProvider were the following two methods:

The first one allows to get a single source of truth for the scanResult-list of known-devices (during advertisement - not during connection)

AsyncValue<ScanResult> getScanResultOfId(String deviceId) {
    return state.whenData((results) {
      return results.firstWhere((result) => result.device.advName == deviceId);
    });
  }

And the second one allows to retrieve the one ScanResult for the given known deviceId: (also only during avertisement and not connection)

 ScanResult? getScanResultOfIdOrNull(String deviceId) {
    if (state.value == []) {
      return null;
    }
    if (state.value?.where((result) => result.device.advName == deviceId) ==
        null) {
      return null;
    } else if (state.value!
        .where((result) => result.device.advName == deviceId)
        .isEmpty) {
      return null;
    } else {
      return state.value
          ?.where((result) => result.device.advName == deviceId)
          .first;
    }
  }
  1. I implemented an automatic connection to the selected device - happening right at the moment of navigation from parent- to detail-screen. And an automatic disconnect when navigating back. And since my observation is that the connection takes a bit of time (and especially the scanResult is still alive during removeIfGone-duration, the user might navigate back during that time. Therefore I went for the following disconnect method inside riverpod. i.e. it makes a sweet distinction weather scanResult is gone or not yet. This makes the customer-experience for fast navigation very top!
void disconnectDevice(String teddyId) {
    ScanResult? scanResult = getScanResultOfIdOrNull(teddyId);
    if (scanResult != null) {
      debugPrint("BLE device Disconnected");
      _bleDevice = null;
      scanResult.device.disconnect();
    } else {
      if (_bleDevice != null) {
        debugPrint("BLE device Disconnected");
        _bleDevice?.disconnect();
        _bleDevice = null;
      }
    }
  }

You can now close this ticket from my side.

Again many thanks for your patience and support. We are all learning ;)

chipweinberger commented 11 months ago

glad you figured it out

1b. I have to say, it still appears very very strange to me that the dissappearing of this known-device after connect happens precisely after the removeIfGone-duration :/ (but this must be a funny coincidence I guess). The coincidence was reason for me to hunt for the ghost that does not exist.

this is how removeIfGone works. It removes the device from the list after it stops advertising for X duration. Your device stops advertising when it is connected, so after X duration it is removed from the list.