kable icon indicating copy to clipboard operation
kable copied to clipboard

[Feature] Scanner: easy way to detect when device is no longer available

Open rocketraman opened this issue 2 years ago • 6 comments

The Scanner should provide a way to determine when a device is no longer available, because it has been turned off or is no longer in range.

As I understand it, at least on Android, the right way to do this is to scan again, and determine from the new scan if any devices are no longer present relative to the previous scan.

See https://stackoverflow.com/questions/33033906/how-to-detect-when-a-ble-device-is-not-in-range-anymore.

rocketraman avatar Jul 05 '23 21:07 rocketraman

I'm reluctant to implement such a feature in Kable since I believe what should be considered "no longer available" comes down a lot to the needs of the app that is using Kable.

Additionally, I think implementing such logic app-side should be fairly straight forward?

Something to the effect of (pseudo code):

// Fake advertisements identified as a single character.
val advertisements = MutableSharedFlow<Char>()
launch {
    listOf(
        'a', 'b', 'e', 'f', 'a', 'c', 'd', 'a', 'a', 'a', 'b', 'd', 'e', 'a', 'b', 'b', 'a',
        'a', 'b', 'b', 'a', 'b', 'a', 'b', 'b', 'a', 'b', 'a', 'b', 'b', 'a', 'b', 'a', 'b',
    ).forEach {
        advertisements.emit(it)
        delay(1.seconds)
    }
}

val mark1 = Clock.System.asTimeSource().markNow() + 10.24.seconds
val pass1 = advertisements.takeWhile { mark1.hasNotPassedNow() }.toSet()

val mark2 = Clock.System.asTimeSource().markNow() + 10.24.seconds
val pass2 = advertisements.takeWhile { mark2.hasNotPassedNow() }.toSet()

val noLongerAvailable = pass1 - pass2
println(noLongerAvailable)

twyatt avatar Jul 06 '23 07:07 twyatt

Thanks I'll experiment with this. If it does work, then what do you think about a utility method on advertisements like scanOneInterval()? That would abstract away the low-level detail of the 10.24 seconds that covers one advertising interval.

rocketraman avatar Jul 06 '23 11:07 rocketraman

I'm reluctant to add such a utility method only because (as far as I understand) advertisement interval is customizable (on the peripheral side), so some firmware might use a more frequent interval while others use less, and 10.24 is just the maximum — so the naming scanOneInterval may not be accurate for all peripherals.

The timeout could be configurable (and default to 10.24), but that becomes a lot of code in Kable for something that is fairly trivial to implement on the consumer side?

Ultimately, I appreciate the suggestion and always open to further discussion (I'm just cautious about growing the API unnecessarily).

twyatt avatar Jul 06 '23 19:07 twyatt

This approach works but there are a few complexities to keep in mind.

  1. You can't use toSet directly, as (at least for my device) each AndroidAdvertisement is not equal, even though its the same address. Mapping to the address field of the advertisement is needed for the set comparison.

  2. Obvious in hindsight but caused some problems initially: BLE devices stop advertising when they are connected. Therefore, we can't assume an already connected device is "gone" relative to the current device when it is no longer present in the advertisements.

I went with an approach that looks something like this. My peripheral advertises every 250 ms so a 1 second chunk time was sufficient, and this approach does not require multiple separate scans:

      var visibleAddresses = emptySet<String>()
      // any peripherals already connected from other app state
      var connectedAddresses = setOf(...)

      scanner.advertisements
         // custom implementation
         // see https://github.com/Kotlin/kotlinx.coroutines/issues/1302
        .chunked(100, 1.seconds)
        .map { it.associateBy { it.address } }
        .distinctUntilChanged()
        .collect { ads ->
          val removed = lastAddresses - connectedAddresses - ads.keys
          val added = ads.keys - lastAddresses
          if (added.isNotEmpty() || removed.isNotEmpty()) {
            lastAddresses = ads.keys
          }
        }

If you are curious about my implementation of chunked I can share it.

I don't feel this is trivial to do for an application consumer, so to me it makes sense to have some of this logic in Kable. However, I understand not wanting to expand the API surface. Feel free to close this issue.

rocketraman avatar Jul 08 '23 02:07 rocketraman

I see. You've definitely exposed more complexity than I had originally anticipated.

I'll keep the issue open, as it allows others to discover the feature request and 👍 if they too would find it useful. That would at least gauge interest.

~~I'm still on the fence as to if it should be added to Kable, especially with needing a custom (chunked) operator, makes for a potentially larger maintenance burden. Lots of tradeoffs to consider.~~ Regardless, I would appreciate you sharing your chunked implementation, it would prove useful if later we do decide to add this feature.

EDIT: I'd be happy to have extra features like this live in a separate module/artifact.

twyatt avatar Jul 08 '23 06:07 twyatt

@rocketraman if you're up for creating a PR, I'd be happy to have the feature live in a separate module/artifact that consumers can optionally pull into their projects.

twyatt avatar Jul 09 '23 02:07 twyatt