//! Device discovery and connection state (FR-1, FR-9.1–9.3). //! //! A real BLE scanner. A background task drives `bikecontrol_ble::scan` and //! publishes its results on a `watch` channel; [`DeviceRegistry`] — which lives //! inside the app's synchronous `Mutex` and therefore may never `.await` — //! reads that channel, merges in the trainer supervisor's status, and produces //! the `DeviceInfo` list the UI renders. //! //! ```text //! scan task ──watch──┐ //! ├──► DeviceRegistry::poll ──► DeviceInfo[] //! trainer ──watch─┘ //! ``` //! //! The behaviour that matters, and the reason `control_acquired` is a field //! rather than a state: **BLE connection and FTMS control acquisition are //! separate steps** (FR-9.3). A trainer goes `Connecting → Connected → //! Controlling`, and it can sit at `Connected` indefinitely if the control //! point is refused. use std::collections::{HashMap, HashSet}; use std::time::Duration; use bikecontrol_ble::scan::{self, DiscoveredDevice, ScanKind, ZWIFT_SERVICE}; use bikecontrol_ble::uuids; use bikecontrol_ble::PodId; use bikecontrol_core::types::ConnectionState; use serde::{Deserialize, Serialize}; use tokio::sync::watch; use uuid::Uuid; use crate::controller::{ControllerHandle, PodState}; 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); /// 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 /// cadence: nothing the rider can do about a missing radio happens in 400 ms. const ADAPTER_RETRY: Duration = Duration::from_secs(2); /// What to suggest when there is no adapter. The remedy is platform-specific /// and telling an Android rider to check BlueZ is worse than saying nothing. #[cfg(target_os = "android")] const ADAPTER_HINT: &str = "Check Bluetooth is switched on and BikeControl is allowed to use it."; /// Shown when we *know* the radio is off, which is a different message: there is /// one specific thing to do and `request_bluetooth_enable` will offer to do it. #[cfg(target_os = "android")] const BLUETOOTH_OFF: &str = "Bluetooth is switched off."; #[cfg(target_os = "linux")] const ADAPTER_HINT: &str = "Check the radio is on and BlueZ is running."; #[cfg(not(any(target_os = "android", target_os = "linux")))] const ADAPTER_HINT: &str = "Check the radio is on."; /// Heart Rate Service, so an HRM in the room is labelled rather than "unknown". const HEART_RATE_SERVICE: Uuid = Uuid::from_u128(0x0000180d_0000_1000_8000_00805f9b34fb); /// What we think a peripheral is, from its advertised services and /// manufacturer data (FR-1.2). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum DeviceKind { /// Advertises FTMS (`0x1826`). Trainer, /// A Click pod, named for the shift paddle it carries — the type byte in /// its manufacturer data says which (§2.3.1, FR-1.4). ClickMinus, ClickPlus, HeartRate, Unknown, } impl DeviceKind { /// Which Click pod this row is, if it is one at all. pub fn pod_id(self) -> Option { match self { DeviceKind::ClickMinus => Some(PodId::Minus), DeviceKind::ClickPlus => Some(PodId::Plus), _ => None, } } } #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct DeviceInfo { pub id: String, pub name: String, pub address: String, /// dBm. Roughly −40 (touching) to −95 (barely there). 0 when the adapter /// did not report one. pub rssi: i16, pub kind: DeviceKind, pub state: ConnectionState, /// FTMS control point acquired (FR-2.1). **Connected ≠ controllable** /// (FR-9.3) — this is deliberately a separate field, not a state. pub control_acquired: bool, pub services: Vec, /// Previously paired, so it would auto-connect on launch (FR-1.5). pub remembered: bool, pub battery_pct: Option, // No unlock countdown. FR-3.9 assumed the Click v2 needed its Zwift session // refreshing daily; TASK-0 disproved it on this hardware — the pods answer // `RideOn 00 09` unencrypted, with no key exchange and no expiry (§2.3.1). // The field was always `None`, which the UI rendered as "expired" against a // pod that was working perfectly. /// Human-readable failure, shown verbatim in the UI (FR-9.2). pub error: Option, } /// Outcome of one registry tick. pub struct PollResult { pub changed: bool, /// Devices whose connection state settled this tick. pub transitions: Vec, /// Trainer status, when it changed since the last poll. The caller turns /// this into user-facing notices (FR-1.8, FR-9.4). pub trainer_changed: Option, } /// What the scan task publishes. #[derive(Debug, Clone, Default)] pub struct ScanSnapshot { pub devices: Vec, /// Adapter-level failure — no radio, BlueZ down. Surfaced verbatim. pub error: Option, /// Bumped every completed pass, so `poll` can tell "same devices" from /// "scanner has not run yet". pub generation: u64, } pub struct DeviceRegistry { trainer: TrainerHandle, /// Held so a Click row in the device list connects the same way its card on /// the connection screen does — one path, not two that can disagree. controller: ControllerHandle, scan_rx: watch::Receiver, scan_on: watch::Sender, forgotten: HashSet, remembered: HashSet, /// The list published last tick, for change detection. published: Vec, last_trainer: TrainerStatus, pub scanning: bool, /// Scanning was switched off by *us*, to get out of the way of a connect — /// not by the rider. Only a suspension is resumed automatically (FR-1.12). scan_suspended: bool, /// The most recent adapter error, so the UI can say why the list is empty. pub error: Option, } impl DeviceRegistry { pub fn new(trainer: TrainerHandle, controller: ControllerHandle) -> Self { let (scan_on, scan_on_rx) = watch::channel(false); let (scan_tx, scan_rx) = watch::channel(ScanSnapshot::default()); tauri::async_runtime::spawn(scan_loop(scan_on_rx, scan_tx)); Self { last_trainer: trainer.status(), trainer, controller, scan_rx, scan_on, forgotten: HashSet::new(), remembered: HashSet::new(), published: Vec::new(), scanning: false, scan_suspended: false, error: None, } } /// Rider-initiated. Cancels any suspension: an explicit request outranks /// our own bookkeeping in both directions. pub fn start_scan(&mut self) { self.scan_suspended = false; self.set_scanning(true); } pub fn stop_scan(&mut self) { self.scan_suspended = false; self.set_scanning(false); } fn set_scanning(&mut self, on: bool) { self.scanning = on; let _ = self.scan_on.send(on); } /// Rebuild the device list from the scanner and the trainer supervisor. pub fn poll(&mut self) -> PollResult { let trainer = self.trainer.status(); let trainer_changed = (trainer != self.last_trainer).then(|| trainer.clone()); self.last_trainer = trainer.clone(); // The scan is switched off for the duration of a connect so that it and // `find_peripheral` do not fight over the one adapter — and nothing else // ever turns it back on. A disconnect, or a connect that failed, would // otherwise leave the list frozen on a snapshot taken before the attempt // and the rider with no way to find anything but the Scan button // (FR-1.12, NFR-7). if should_resume_scan(self.scan_suspended, &trainer) { self.scan_suspended = false; self.set_scanning(true); } let next = self.build(&trainer); let transitions = state_transitions(&self.published, &next); let changed = next != self.published; self.published = next; PollResult { changed, transitions, trainer_changed, } } /// Merge the scan snapshot with the trainer's live status. fn build(&mut self, trainer: &TrainerStatus) -> Vec { let snapshot = self.scan_rx.borrow().clone(); self.error = snapshot.error.clone(); let controller = self.controller.status(); let mut out: Vec = Vec::with_capacity(snapshot.devices.len() + 1); for d in &snapshot.devices { let id = d.address.clone(); if self.forgotten.contains(&id) { continue; } let kind = classify(d); // A Click advertises for a few seconds after a button press and // then goes back to sleep (A-4). Nobody can reliably press Connect // inside that window, and there is nothing to decide anyway — this // is the pod the rider already told us about by pressing a button // on it. So the scan connects it (FR-1.5), unless they disconnected // it on purpose, in which case the supervisor ignores this. if let Some(pod) = kind.pod_id() { self.controller.pod_seen(pod, &id); } out.push(DeviceInfo { kind, name: d.label(), address: d.address.clone(), rssi: d.rssi.unwrap_or(0), state: if self.scanning { ConnectionState::Scanning } else { ConnectionState::Idle }, control_acquired: false, services: d.services.iter().map(|u| describe_service(*u)).collect(), remembered: self.remembered.contains(&id), battery_pct: None, error: None, id, }); } // A connected peripheral usually stops appearing in scan results, and // the trainer must not vanish from the list the moment it is in use. if let Some(address) = trainer.address.clone() { let existing = out.iter().position(|d| d.id == address); let idx = match existing { Some(i) => i, None => { out.push(DeviceInfo { id: address.clone(), name: trainer.name.clone().unwrap_or_else(|| "Trainer".into()), address, rssi: 0, kind: DeviceKind::Trainer, state: ConnectionState::Idle, control_acquired: false, services: vec![describe_service(uuids::FITNESS_MACHINE_SERVICE)], remembered: true, battery_pct: None, error: None, }); out.len() - 1 } }; let device = &mut out[idx]; device.kind = DeviceKind::Trainer; device.state = trainer.state.clone(); device.control_acquired = trainer.control_acquired; device.remembered = true; device.error = trainer.error.clone().or_else(|| { trainer .stale .then(|| "Connected but sending no data — pedal to wake it".to_string()) }); if let Some(name) = &trainer.name { device.name = name.clone(); } } // The same for each Click pod, and for the same reason: a connected pod // stops advertising, and a row that disappears the moment the pod works // reads as a pod that has gone (FR-1.4). This is a *view* of what the // controller supervisor owns — the panel above the list and this row // are the same link, never two. for id in PodId::BOTH { let pod = controller.get(id); let Some(address) = pod.address.clone() else { continue; }; if self.forgotten.contains(&address) { continue; } let kind = match id { PodId::Minus => DeviceKind::ClickMinus, PodId::Plus => DeviceKind::ClickPlus, }; let idx = match out.iter().position(|d| d.id == address) { Some(i) => i, None => { out.push(DeviceInfo { id: address.clone(), name: pod.name.clone().unwrap_or_else(|| "Zwift Click".into()), address, rssi: 0, kind, state: ConnectionState::Idle, control_acquired: false, services: vec![describe_service(ZWIFT_SERVICE)], remembered: true, battery_pct: None, error: None, }); out.len() - 1 } }; let device = &mut out[idx]; device.kind = kind; device.battery_pct = pod.battery_percent; device.error = pod.error.clone(); device.remembered = true; device.state = match pod.state { PodState::Connected => ConnectionState::Connected, PodState::Searching => ConnectionState::Connecting, PodState::Reconnecting => ConnectionState::Reconnecting, PodState::GaveUp => ConnectionState::Lost { reason: "stopped answering".into(), }, // Nothing has been asked of this pod, so whatever the scan says // about it stands. PodState::Idle => device.state.clone(), }; } // Trainers first, then by signal strength: the thing the rider is // looking for should not be below an unnamed peripheral. out.sort_by(|a, b| { (a.kind != DeviceKind::Trainer) .cmp(&(b.kind != DeviceKind::Trainer)) .then(b.rssi.cmp(&a.rssi)) .then(a.id.cmp(&b.id)) }); out } pub fn list(&self) -> Vec { self.published.clone() } pub fn get(&self, id: &str) -> Option { self.published.iter().find(|d| d.id == id).cloned() } pub fn connect(&mut self, id: &str) -> Result { let device = self .get(id) .ok_or_else(|| format!("no such device: {id}"))?; // A Click row connects the pod it *is*, by address. Routed to the // controller supervisor rather than handled here, so the device list // and the connection screen drive the same one link per pod (FR-1.4). if let Some(pod) = device.kind.pod_id() { self.remembered.insert(id.to_string()); self.forgotten.remove(id); self.controller.connect(pod, Some(device.address.clone())); let mut info = device; info.state = ConnectionState::Connecting; info.error = None; info.remembered = true; return Ok(info); } if device.kind != DeviceKind::Trainer { return Err(format!( "{} is neither a trainer nor a Click pod — there is nothing to connect to.", device.name )); } // A second click on an already-connected trainer must not tear the // working session down and start over. Connecting takes ~8 s against // this hardware, which is easily long enough for an impatient rider to // click again and destroy the connection they were waiting for. let status = self.trainer.status(); if status.address.as_deref() == Some(device.address.as_str()) { match status.state { ConnectionState::Connecting | ConnectionState::Scanning => { return Err(format!("Already connecting to {}…", device.name)) } ConnectionState::Reconnecting => { return Err(format!("Reconnecting to {} — hold on.", device.name)) } ConnectionState::Connected | ConnectionState::Controlling => { return Err(format!( "{} is already connected. Disconnect first to start over.", device.name )) } ConnectionState::Idle | ConnectionState::Lost { .. } => {} } } // Our own scan and the client's `find_peripheral` would otherwise fight // over the one adapter. Suspended, not stopped: `poll` puts it back as // soon as the trainer is no longer attached (FR-1.12). self.set_scanning(false); self.scan_suspended = true; self.remembered.insert(id.to_string()); self.forgotten.remove(id); self.trainer .connect(scan::TrainerSelector::Address(device.address.clone())); let mut info = device; info.state = ConnectionState::Connecting; info.control_acquired = false; info.error = None; info.remembered = true; Ok(info) } pub fn disconnect(&mut self, id: &str) -> Result { let mut device = self .get(id) .ok_or_else(|| format!("no such device: {id}"))?; if device.kind == DeviceKind::Trainer { // SAF-2 runs inside the supervisor before the link drops. self.trainer.disconnect(); } if let Some(pod) = device.kind.pod_id() { self.controller.disconnect(Some(pod)); } device.state = ConnectionState::Idle; device.control_acquired = false; Ok(device) } pub fn forget(&mut self, id: &str) -> Result<(), String> { let device = self .get(id) .ok_or_else(|| format!("no such device: {id}"))?; if device.kind == DeviceKind::Trainer && device.control_acquired { self.trainer.disconnect(); } self.remembered.remove(id); self.forgotten.insert(id.to_string()); self.published.retain(|d| d.id != id); Ok(()) } /// The address of each Click pod the scanner has seen (FR-1.4). /// /// Connecting by address is both faster and unambiguous: the pods share a /// local name, so anything that goes looking for "a Zwift Click" is picking /// one of the two at random. The scan has already done the identifying /// work — this hands it to the controller supervisor rather than making it /// scan again. pub fn click_pod_addresses(&self) -> HashMap { let mut out = HashMap::new(); for device in &self.published { if let Some(pod) = device.kind.pod_id() { out.entry(pod).or_insert_with(|| device.address.clone()); } } out } /// True once a trainer is connected *and* controllable — the precondition /// for a real ride (FR-2.1). pub fn trainer_controllable(&self) -> bool { self.trainer.status().controllable() } pub fn trainer_status(&self) -> TrainerStatus { self.trainer.status() } } /// FR-1.2. FTMS is checked first: the D100 advertises the Zwift custom service /// too, so "is a Zwift device" is not enough to call something a Click. fn classify(d: &DiscoveredDevice) -> DeviceKind { if d.is_fitness_machine() { return DeviceKind::Trainer; } if d.services.contains(&HEART_RATE_SERVICE) { return DeviceKind::HeartRate; } // Which pod comes from the manufacturer-data type byte (§2.3.1) — the one // thing that distinguishes the pair, since both advertise the same name. match d.pod_id() { Some(PodId::Minus) => return DeviceKind::ClickMinus, Some(PodId::Plus) => return DeviceKind::ClickPlus, // A Zwift device we cannot place: a v1 Click, or the trainer's own // Zwift service. Labelled by the service rather than guessed at. None if d.services.contains(&ZWIFT_SERVICE) || d.is_zwift_device() => { return DeviceKind::Unknown } None => {} } DeviceKind::Unknown } fn describe_service(uuid: Uuid) -> String { match uuids::well_known_name(uuid) { Some(name) => format!("{uuid} ({name})"), None if uuid == ZWIFT_SERVICE => format!("{uuid} (Zwift custom)"), None => uuid.to_string(), } } /// May a scan we suspended for a connect be switched back on? /// /// FR-1.12. Split out from [`DeviceRegistry::poll`] so the rule is checkable /// without a radio. Only a suspension of *ours* is resumed — a rider who /// pressed Stop scan meant it. fn should_resume_scan(suspended: bool, trainer: &TrainerStatus) -> bool { suspended && !trainer.is_attached() } /// Devices whose connection state or control acquisition changed (FR-1.7). fn state_transitions(before: &[DeviceInfo], after: &[DeviceInfo]) -> Vec { after .iter() .filter(|d| match before.iter().find(|p| p.id == d.id) { None => d.state != ConnectionState::Scanning && d.state != ConnectionState::Idle, Some(prev) => prev.state != d.state || prev.control_acquired != d.control_acquired, }) .cloned() .collect() } /// Drive the radio. Runs for the life of the process; a failure to get an /// adapter is reported through the snapshot rather than killing the task, so /// plugging a dongle in later recovers on its own (NFR-4). async fn scan_loop(mut on: watch::Receiver, tx: watch::Sender) { let mut generation = 0u64; loop { if !*on.borrow() { // Wait to be switched on rather than spinning. if on.changed().await.is_err() { return; } continue; } // Android only: the Java backend is initialised from MainActivity and // calling btleplug before that has succeeded panics rather than erroring // (see `android::ready`). Bluetooth switched off at launch is enough to // land here; MainActivity retries on every resume, so this is a wait // rather than a dead end, and the rider gets told which knob to turn. #[cfg(target_os = "android")] if !crate::android::ready() || crate::android::bluetooth_enabled() == Some(false) { generation += 1; let _ = tx.send(ScanSnapshot { devices: Vec::new(), error: Some(BLUETOOTH_OFF.into()), generation, }); tokio::time::sleep(ADAPTER_RETRY).await; continue; } let adapter = match scan::default_adapter().await { Ok(a) => a, Err(e) => { tracing::warn!(error = %e, "no Bluetooth adapter"); generation += 1; let _ = tx.send(ScanSnapshot { devices: Vec::new(), error: Some(format!("{e}. {ADAPTER_HINT}")), generation, }); tokio::time::sleep(ADAPTER_RETRY).await; continue; } }; // 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, 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, } } }; let _ = tx.send(snapshot); tokio::time::sleep(IDLE_POLL).await; } } #[cfg(test)] mod tests { use super::*; fn info(id: &str, state: ConnectionState, control: bool) -> DeviceInfo { DeviceInfo { id: id.into(), name: id.into(), address: id.into(), rssi: -50, kind: DeviceKind::Trainer, state, control_acquired: control, services: Vec::new(), remembered: false, battery_pct: None, error: None, } } #[test] fn acquiring_control_is_a_transition_even_though_the_state_is_unchanged() { // FR-9.3: Connected → Controlling *and* Connected-with-control are both // events the UI must see. let before = vec![info("t", ConnectionState::Connected, false)]; let after = vec![info("t", ConnectionState::Connected, true)]; let t = state_transitions(&before, &after); assert_eq!(t.len(), 1); assert!(t[0].control_acquired); } #[test] fn a_device_merely_appearing_in_a_scan_is_not_a_transition() { let after = vec![info("t", ConnectionState::Scanning, false)]; assert!(state_transitions(&[], &after).is_empty()); let after = vec![info("t", ConnectionState::Idle, false)]; assert!(state_transitions(&[], &after).is_empty()); } #[test] fn a_device_that_appears_already_connected_is_a_transition() { let after = vec![info("t", ConnectionState::Controlling, true)]; assert_eq!(state_transitions(&[], &after).len(), 1); } #[test] fn a_lost_link_is_reported() { let before = vec![info("t", ConnectionState::Controlling, true)]; let after = vec![info( "t", ConnectionState::Lost { reason: "gone".into(), }, false, )]; let t = state_transitions(&before, &after); assert_eq!(t.len(), 1); assert!(!t[0].control_acquired); } #[test] fn an_unchanged_list_produces_no_transitions() { let list = vec![info("t", ConnectionState::Controlling, true)]; assert!(state_transitions(&list, &list).is_empty()); } #[test] fn a_scan_suspended_for_a_connect_comes_back_when_the_connect_is_over() { // FR-1.12. Connecting switches the scan off so it does not fight // `find_peripheral` over the one adapter, and before this nothing ever // switched it back on: a disconnect or a failed connect left the device // list frozen on a snapshot taken before the attempt. let idle = TrainerStatus::default(); let lost = TrainerStatus { state: ConnectionState::Lost { reason: "gone".into(), }, ..TrainerStatus::default() }; assert!(should_resume_scan(true, &idle)); assert!(should_resume_scan(true, &lost)); // Still in progress, or in use: leave the adapter alone. let connecting = TrainerStatus { state: ConnectionState::Connecting, ..TrainerStatus::default() }; let reconnecting = TrainerStatus { state: ConnectionState::Reconnecting, ..TrainerStatus::default() }; let riding = TrainerStatus { state: ConnectionState::Controlling, control_acquired: true, ..TrainerStatus::default() }; assert!(!should_resume_scan(true, &connecting)); assert!(!should_resume_scan(true, &reconnecting)); assert!(!should_resume_scan(true, &riding)); // The rider pressed Stop scan. That is not ours to undo. assert!(!should_resume_scan(false, &idle)); } #[test] fn well_known_services_are_named_and_zwift_is_recognised() { let ftms = describe_service(uuids::FITNESS_MACHINE_SERVICE); assert!(ftms.contains("Fitness Machine"), "{ftms}"); let zwift = describe_service(ZWIFT_SERVICE); assert!(zwift.contains("Zwift"), "{zwift}"); } }