From bbd11757ee4497afa5a2c3bd7e5ab570487ad835 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Thu, 27 Aug 2026 13:24:39 +0200 Subject: [PATCH] Give up the radio for the link, not for the search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/ble/src/click.rs | 3 +++ crates/ble/src/client.rs | 4 ++++ crates/ble/src/heart_rate.rs | 2 ++ crates/ble/src/scan.rs | 36 ++++++++++++++++++++++++++++++++++++ src-tauri/src/controller.rs | 24 +++++++++++++++++++++--- src-tauri/src/devices.rs | 25 +++++++++++++++++++++++++ src-tauri/tauri.conf.json | 4 ++-- 7 files changed, 93 insertions(+), 5 deletions(-) diff --git a/crates/ble/src/click.rs b/crates/ble/src/click.rs index 8c01420..a301750 100644 --- a/crates/ble/src/click.rs +++ b/crates/ble/src/click.rs @@ -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()); diff --git a/crates/ble/src/client.rs b/crates/ble/src/client.rs index 53327e4..ee2bb32 100644 --- a/crates/ble/src/client.rs +++ b/crates/ble/src/client.rs @@ -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 diff --git a/crates/ble/src/heart_rate.rs b/crates/ble/src/heart_rate.rs index 13241dc..49d0bb3 100644 --- a/crates/ble/src/heart_rate.rs +++ b/crates/ble/src/heart_rate.rs @@ -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()); diff --git a/crates/ble/src/scan.rs b/crates/ble/src/scan.rs index 391907b..19bba42 100644 --- a/crates/ble/src/scan.rs +++ b/crates/ble/src/scan.rs @@ -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 diff --git a/src-tauri/src/controller.rs b/src-tauri/src/controller.rs index f5d9bca..9f67bb2 100644 --- a/src-tauri/src/controller.rs +++ b/src-tauri/src/controller.rs @@ -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::(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 — diff --git a/src-tauri/src/devices.rs b/src-tauri/src/devices.rs index 7ccd2b1..780c1df 100644 --- a/src-tauri/src/devices.rs +++ b/src-tauri/src/devices.rs @@ -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, tx: watch::Sender, tx: watch::Sender= window_ends { break; } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ad5194f..32ad03d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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."