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