Give up the radio for the link, not for the search
Two failures on the tablet, one of them mine from an hour ago. The real one first. A trainer that drops mid-ride could not get back: its supervisor reconnects on its own (FR-1.10) and those attempts never pass through `DeviceRegistry::connect`, so nothing suspended the device list for them. On Android a GATT link that is discovering services while a scan is running is killed by the platform, and the log has it exactly — a reconnect at 13:07:18 dying with `Disconnected while discovering services`, inside a list-scan session opened at 13:07:02. My first fix was to suspend the list whenever the trainer was mid-connect. That was drawn around the wrong thing. `Connecting` covers the 15 s *search*, `Reconnecting` covers the backoff between attempts, and against an asleep trainer those alternate for the whole of RECONNECT_ATTEMPTS — so the list scan went off the air for minutes and every other device starved with it. A pod that dropped could never be seen again, which is what "the pods disconnect after 40 seconds" was. The window that matters is narrower than either: connect, then discover services. `scan::gatt_setup` marks it — an RAII guard taken by the trainer, pod and heart rate paths the moment their search returns a peripheral — and the device list yields only for that. Measured on the tablet: 1.2 s of yielding for a heart rate connect, then straight back to one session per 21 s. Also: the "+ pod seen; the − pod speaks for the pair" line is logged once per run of refusals rather than once per sighting. The device list republishes several times a second and every pass re-reported a visible pod — 274 identical lines in five minutes, burying the connect failures the log was being read for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -324,6 +324,9 @@ async fn open_session(
|
||||
in_flight: &InFlight,
|
||||
) -> Result<(Session, Notifications), FtmsError> {
|
||||
let peripheral = find_pod(adapter, selector, config.scan_timeout).await?;
|
||||
// The GATT window — see `scan::gatt_setup`. Scanners stay off the air until
|
||||
// the session is built.
|
||||
let _gatt = scan::gatt_setup();
|
||||
// Cancelling past this point would otherwise strand the link (FR-1.10).
|
||||
in_flight.hold(peripheral.clone());
|
||||
|
||||
|
||||
@@ -1321,6 +1321,10 @@ async fn connect_session(
|
||||
});
|
||||
|
||||
let peripheral = scan::find_peripheral(adapter, selector, config.scan_timeout).await?;
|
||||
// Found. Everything from here to the end of service discovery is the window
|
||||
// Android kills a link for if anything else is scanning, so say so — the
|
||||
// device list watches this and stays off the air until it is over.
|
||||
let _gatt = scan::gatt_setup();
|
||||
// From here until this function returns, cancelling the caller is the only
|
||||
// thing that can leave a link open with nobody to close it. Hand the
|
||||
// peripheral over now, before `connect()` — a cancellation lands wherever it
|
||||
|
||||
@@ -378,6 +378,8 @@ async fn open_session(
|
||||
|d| selector.matches(d),
|
||||
)
|
||||
.await?;
|
||||
// The GATT window — see `scan::gatt_setup`.
|
||||
let _gatt = scan::gatt_setup();
|
||||
// Cancelling past this point would otherwise strand the link (FR-1.10).
|
||||
in_flight.hold(peripheral.clone());
|
||||
|
||||
|
||||
@@ -184,6 +184,42 @@ fn discovery_closed(who: &str, outcome: &str) {
|
||||
tracing::debug!(who, depth, outcome, "discovery: stop");
|
||||
}
|
||||
|
||||
/// How many links are being built right now.
|
||||
///
|
||||
/// The distinction that matters on Android: two *scans* overlapping is
|
||||
/// wasteful, but a scan overlapping a **GATT setup** — connect, then discover
|
||||
/// services — is what the platform kills, with `Disconnected while discovering
|
||||
/// services`. So this marks that narrower window, and the device list stays off
|
||||
/// the air only for it.
|
||||
///
|
||||
/// Not the search that precedes it. A trainer that is asleep is searched for
|
||||
/// every few seconds for minutes on end (`RECONNECT_ATTEMPTS`), and suspending
|
||||
/// the list scan for all of that starves every *other* device of the discovery
|
||||
/// it needs — a dropped pod could never be seen again, which is exactly what
|
||||
/// happened on the tablet when the suspension was drawn around the whole
|
||||
/// reconnect instead of around this.
|
||||
static GATT_SETUP: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
/// Marks a link as under construction until dropped. See [`GATT_SETUP`].
|
||||
#[must_use = "the window lasts as long as the guard is held"]
|
||||
pub struct GattSetup(());
|
||||
|
||||
pub fn gatt_setup() -> GattSetup {
|
||||
GATT_SETUP.fetch_add(1, Ordering::SeqCst);
|
||||
GattSetup(())
|
||||
}
|
||||
|
||||
impl Drop for GattSetup {
|
||||
fn drop(&mut self) {
|
||||
GATT_SETUP.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// True while any link is being built. Scanners must stay off the air.
|
||||
pub fn gatt_setup_active() -> bool {
|
||||
GATT_SETUP.load(Ordering::SeqCst) > 0
|
||||
}
|
||||
|
||||
/// Open a discovery session and leave it open.
|
||||
///
|
||||
/// Paired with [`end`], and sampled meanwhile with [`peek`]. The three exist
|
||||
|
||||
@@ -620,6 +620,9 @@ async fn run(
|
||||
// back whenever the `−` pod is reachable, so it only ever expires against a
|
||||
// `−` pod that is genuinely not coming (see `PLUS_GRACE`).
|
||||
let mut plus_gate = tokio::time::Instant::now() + PLUS_GRACE;
|
||||
// Whether the "+ pod is redundant" refusal has already been logged for the
|
||||
// current state of affairs. See the `Seen` arm.
|
||||
let mut plus_declined = false;
|
||||
|
||||
let (attempt_tx, mut attempt_rx) = mpsc::channel::<Attempt>(4);
|
||||
let mut housekeeping = tokio::time::interval(Duration::from_secs(5));
|
||||
@@ -686,9 +689,20 @@ async fn run(
|
||||
tokio::time::Instant::now() >= plus_gate,
|
||||
)
|
||||
{
|
||||
tracing::debug!(
|
||||
"controller: + pod seen; the − pod speaks for the pair"
|
||||
);
|
||||
// Once per run of refusals, not once per sighting.
|
||||
// The device list republishes several times a
|
||||
// second and every pass re-reports a visible pod,
|
||||
// so this logged twice a second for as long as the
|
||||
// + pod was in the room — 274 lines in five minutes
|
||||
// on the tablet, burying the connect failures we
|
||||
// were reading the log for.
|
||||
if !plus_declined {
|
||||
tracing::debug!(
|
||||
"controller: + pod seen; the − pod speaks for the pair \
|
||||
(silenced until this changes)"
|
||||
);
|
||||
plus_declined = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let slot = slot_mut(&mut minus, &mut plus, pod);
|
||||
@@ -793,6 +807,10 @@ async fn run(
|
||||
// grace period lets the `+` pod in.
|
||||
if !minus.idle() {
|
||||
plus_gate = tokio::time::Instant::now() + PLUS_GRACE;
|
||||
} else {
|
||||
// The − pod is no longer speaking for the pair, so the next
|
||||
// refusal — if there is one — is news again.
|
||||
plus_declined = false;
|
||||
}
|
||||
|
||||
// Converge on one link. The `+` pod may have connected first —
|
||||
|
||||
@@ -58,6 +58,9 @@ const SCAN_WINDOW: Duration = Duration::from_secs(20);
|
||||
const SCAN_SAMPLE: Duration = Duration::from_millis(700);
|
||||
/// Poll interval while scanning is switched off.
|
||||
const IDLE_POLL: Duration = Duration::from_millis(400);
|
||||
/// How often to re-check whether a link has finished being built, while the
|
||||
/// scanner is holding off for one (see `scan::gatt_setup`).
|
||||
const GATT_YIELD: Duration = Duration::from_millis(250);
|
||||
/// How long to wait before looking for the adapter again. Longer than the scan
|
||||
/// cadence: nothing the rider can do about a missing radio happens in 400 ms.
|
||||
const ADAPTER_RETRY: Duration = Duration::from_secs(2);
|
||||
@@ -309,6 +312,7 @@ impl DeviceRegistry {
|
||||
self.set_scanning(true);
|
||||
}
|
||||
|
||||
|
||||
let next = self.build(&trainer);
|
||||
let transitions = state_transitions(&self.published, &next);
|
||||
let changed = next != self.published;
|
||||
@@ -915,6 +919,12 @@ 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.
|
||||
// Wait out any link still being built before opening a session, for the
|
||||
// same reason the sample loop below yields to one.
|
||||
while scan::gatt_setup_active() {
|
||||
tokio::time::sleep(GATT_YIELD).await;
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -970,6 +980,21 @@ async fn scan_loop(mut on: watch::Receiver<bool>, tx: watch::Sender<ScanSnapshot
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Somebody is building a link (`scan::gatt_setup`). Android drops a
|
||||
// GATT connection that is discovering services while a scan runs —
|
||||
// on the tablet that was every automatic reconnect failing with
|
||||
// `Disconnected while discovering services`. So give the radio up
|
||||
// for the second or two it takes, and pick it up again after.
|
||||
//
|
||||
// Only for that window, deliberately. The suspension that covered
|
||||
// the whole reconnect — search and backoff included — kept this
|
||||
// loop off the air for as long as the trainer was asleep, which
|
||||
// starved every other device of discovery: a pod that dropped could
|
||||
// never be seen again.
|
||||
if scan::gatt_setup_active() {
|
||||
outcome = "yielding to a connect";
|
||||
break;
|
||||
}
|
||||
if tokio::time::Instant::now() >= window_ends {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "BikeControl",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.3",
|
||||
"identifier": "paris.tourolle.bikecontrol",
|
||||
"build": {
|
||||
"frontendDist": "../ui/dist",
|
||||
@@ -43,7 +43,7 @@
|
||||
],
|
||||
"category": "Utility",
|
||||
"android": {
|
||||
"versionCode": 1201
|
||||
"versionCode": 1203
|
||||
},
|
||||
"shortDescription": "Indoor cycling trainer control",
|
||||
"longDescription": "Control a smart trainer over BLE, ride gradient profiles and synthetic waveforms, and record the result."
|
||||
|
||||
Reference in New Issue
Block a user