Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
rocketraman opened this issue Jul 5, 2023 · 6 comments
Closed

Comments

@rocketraman
Copy link
Contributor

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.

@twyatt
Copy link
Member

twyatt commented Jul 6, 2023

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)

@rocketraman
Copy link
Contributor Author

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.

@twyatt
Copy link
Member

twyatt commented Jul 6, 2023

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).

@rocketraman
Copy link
Contributor Author

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.

@twyatt
Copy link
Member

twyatt commented Jul 8, 2023

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
Copy link
Member

twyatt commented Jul 9, 2023

@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 twyatt closed this as not planned Won't fix, can't repro, duplicate, stale Jul 30, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants