Stop the scanner throttling itself off the air

Android counts an app's scan *starts*: five in any thirty seconds and the
platform answers `SCAN_FAILED_SCANNING_TOO_FREQUENTLY` and stops
returning results. It does this silently as far as btleplug is concerned,
so the app goes on asking and simply stops being told about anything.

`scan_loop` opened a session every 2.9 s — a 2.5 s window plus 400 ms
idle. **Ten starts per thirty seconds, twice the limit, with nothing else
running.** Add the trainer's 15 s search or a pod's 20 s one, which
`click.rs` notes runs to the full timeout because a Click only advertises
after a button press, and the app spends much of its life muted. A rider
who wakes the pods first is doing exactly the thing that pushes the count
over, and then the trainer cannot be found — not because it is not
advertising, but because the app is no longer allowed to hear it.

Starting a scan is the expensive act, not running one, so hold the
session and sample it:

- scan.rs splits `scan` into `begin` / `peek` / `end`, sharing one
  describe-filter-rank path (`collect`). `scan` stays as the one-shot
  form for the probe tool.
- `scan_loop` opens one session per SCAN_WINDOW (now 20 s) and peeks
  every 700 ms, publishing each time. The radio starts a seventh as
  often and the list updates four times *quicker* than the old
  whole-pass cadence.
- The session is recycled rather than held forever: a new adapter each
  cycle is what drops peripherals that have left the room, so 20 s is
  how stale a departed device may look. That was ~3 s before, and it is
  the one thing this trade gives up.
- The sample loop selects on the scan switch, so a suspension still
  lands immediately. A connect suspends this loop precisely so the two
  do not fight over the radio, and a suspension that took twenty seconds
  to arrive would be no suspension at all.

Instrumented at the choke point every caller passes through, because
"the radio is busy" and "the peripheral is asleep" look identical from
outside: every session start logs the concurrent depth and how many
starts there have been in the last thirty seconds, and warns when either
number is a problem. Measured on the tablet after this change — one
start per ~21 s, `recent=2`, against a limit of five.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 13:05:53 +02:00
co-authored by Claude Opus 5
parent 9cbc5aa292
commit e589086078
2 changed files with 200 additions and 32 deletions
+83 -24
View File
@@ -35,9 +35,27 @@ use crate::heart_rate::{HeartRateHandle, HeartRateStatus};
use crate::known::KnownDevices;
use crate::trainer::{TrainerHandle, TrainerStatus};
/// One pass of the scanner. Long enough for a trainer to advertise, short
/// enough that the list feels live.
const SCAN_WINDOW: Duration = Duration::from_millis(2500);
/// How long one discovery session is held before it is recycled.
///
/// **This is a rate limit, not a dwell time.** Since Android 7 the platform
/// counts an app's scan *starts* and blocks it with
/// `SCAN_FAILED_SCANNING_TOO_FREQUENTLY` after five in any thirty seconds — and
/// it does so silently, so the app keeps asking and simply stops being told
/// about anything. This loop used to open a session every 2.9 s: **ten starts
/// per thirty seconds, double the limit, all by itself**, before the trainer's
/// 15 s search or a pod's 20 s search asked for one too. A rider who woke their
/// pods first spent the pods' search window pushing the count over, and then
/// the trainer could not be found — not because it was not advertising, but
/// because the app was no longer allowed to hear it.
///
/// One session per twenty seconds is three starts a minute with the connect
/// paths included, which leaves headroom under the limit on the worst day.
const SCAN_WINDOW: Duration = Duration::from_secs(20);
/// How often the open session is sampled. The list is republished each time, so
/// this — not [`SCAN_WINDOW`] — is how live the screen feels, and it is now
/// four times quicker than the old whole-pass cadence while starting the radio
/// a seventh as often.
const SCAN_SAMPLE: Duration = Duration::from_millis(700);
/// Poll interval while scanning is switched off.
const IDLE_POLL: Duration = Duration::from_millis(400);
/// How long to wait before looking for the adapter again. Longer than the scan
@@ -897,30 +915,71 @@ async fn scan_loop(mut on: watch::Receiver<bool>, tx: watch::Sender<ScanSnapshot
// FR-1.1 lists every peripheral, not only fitness machines: a trainer
// is not obliged to advertise FTMS, and the rider needs to see what is
// in the room to know the scan is working at all.
let result = scan::scan(&adapter, SCAN_WINDOW, ScanKind::All).await;
generation += 1;
let snapshot = match result {
Ok(devices) => ScanSnapshot {
devices,
error: None,
if let Err(e) = scan::begin(&adapter, ScanKind::All, "device list").await {
// Debug as well as Display, because the useful half of a BLE
// failure is usually in the source chain that Display drops.
// "bluetooth error: JNI call failed" was the *entire* symptom of a
// detached-thread bug; the Debug form said
// `Bluetooth(Other(JniCall(ThreadDetached)))` and would have named
// it outright.
tracing::warn!(error = %e, cause = ?e, "scan failed to start");
generation += 1;
let _ = tx.send(ScanSnapshot {
devices: Vec::new(),
error: Some(e.to_string()),
generation,
},
Err(e) => {
// Debug as well as Display, because the useful half of a BLE
// failure is usually in the source chain that Display drops.
// "bluetooth error: JNI call failed" was the *entire* symptom of
// a detached-thread bug; the Debug form said
// `Bluetooth(Other(JniCall(ThreadDetached)))` and would have
// named it outright.
tracing::warn!(error = %e, cause = ?e, "scan failed");
ScanSnapshot {
devices: Vec::new(),
error: Some(e.to_string()),
generation,
});
tokio::time::sleep(ADAPTER_RETRY).await;
continue;
}
// Sample the open session until the window is up — or until the scan is
// switched off, which must be honoured *now* rather than at the end of
// the window. A connect suspends this loop precisely so the two do not
// fight over the radio, and a suspension that took twenty seconds to
// land would be no suspension at all.
let window_ends = tokio::time::Instant::now() + SCAN_WINDOW;
let mut outcome = "recycled";
loop {
tokio::select! {
_ = tokio::time::sleep(SCAN_SAMPLE) => {}
changed = on.changed() => {
if changed.is_err() || !*on.borrow() {
outcome = "suspended";
break;
}
}
}
};
let _ = tx.send(snapshot);
generation += 1;
match scan::peek(&adapter, ScanKind::All).await {
Ok(devices) => {
let _ = tx.send(ScanSnapshot {
devices,
error: None,
generation,
});
}
Err(e) => {
tracing::warn!(error = %e, cause = ?e, "scan sample failed");
let _ = tx.send(ScanSnapshot {
devices: Vec::new(),
error: Some(e.to_string()),
generation,
});
outcome = "failed";
break;
}
}
if tokio::time::Instant::now() >= window_ends {
break;
}
}
// Recycled rather than held forever: a peripheral that stops
// advertising stays in the backend's map until the session ends, so
// without this the list would accumulate devices that left the room.
// Twenty seconds is how stale a departed device may look.
scan::end(&adapter, "device list", outcome).await;
tokio::time::sleep(IDLE_POLL).await;
}
}