Files
BikeControl/src-tauri/src/devices.rs
T
dtourolleandClaude Opus 5 a4670f4992 Attach the runtime threads to the JVM, or nothing scans
Every scan on the phone failed with "bluetooth error: JNI call failed",
which is the entire symptom: jni's Display for JniCall drops the source
that says what actually went wrong. It was ThreadDetached.

droidplug reaches the JVM through JavaVM::get_env(), which does not
attach — it fails outright on any thread the JVM has never seen. Every
BLE call in this app is made from a Tauri task, and Tauri's default
runtime spawns plain Rust worker threads, so on Android no BLE call could
ever have worked. This was invisible until it ran on hardware: the
desktop build shares the code and does not care.

Attaching inside the tasks would not have fixed it. A Tokio task can move
to another worker at any .await, so the thread that starts a scan is not
necessarily the one that polls it next — the attachment has to belong to
the threads, not the work. on_thread_start is the hook that gets that
right, and it covers the blocking pool too. Permanent rather than scoped,
because a scoped attachment detaches at the end of the guard, which for a
worker thread means after the first task it runs.

Ordering is load-bearing at both ends. The JVM is stashed in
initBtleplug, which runs before the super chain that starts us, so it is
there when the runtime is built; and the runtime is installed before
tauri::Builder, because async_runtime::set only affects later spawns.

The scan log now carries the Debug form as well as Display. The chain
read `Bluetooth(Other(JniCall(ThreadDetached)))` all along and would have
named this in the first minute rather than the last.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 20:26:59 +02:00

737 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Device discovery and connection state (FR-1, FR-9.19.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<ScanSnapshot>──┐
//! ├──► DeviceRegistry::poll ──► DeviceInfo[]
//! trainer ──watch<TrainerStatus>─┘
//! ```
//!
//! 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<PodId> {
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<String>,
/// Previously paired, so it would auto-connect on launch (FR-1.5).
pub remembered: bool,
pub battery_pct: Option<u8>,
// 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<String>,
}
/// Outcome of one registry tick.
pub struct PollResult {
pub changed: bool,
/// Devices whose connection state settled this tick.
pub transitions: Vec<DeviceInfo>,
/// 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<TrainerStatus>,
}
/// What the scan task publishes.
#[derive(Debug, Clone, Default)]
pub struct ScanSnapshot {
pub devices: Vec<DiscoveredDevice>,
/// Adapter-level failure — no radio, BlueZ down. Surfaced verbatim.
pub error: Option<String>,
/// 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<ScanSnapshot>,
scan_on: watch::Sender<bool>,
forgotten: HashSet<String>,
remembered: HashSet<String>,
/// The list published last tick, for change detection.
published: Vec<DeviceInfo>,
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<String>,
}
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<DeviceInfo> {
let snapshot = self.scan_rx.borrow().clone();
self.error = snapshot.error.clone();
let controller = self.controller.status();
let mut out: Vec<DeviceInfo> = 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<DeviceInfo> {
self.published.clone()
}
pub fn get(&self, id: &str) -> Option<DeviceInfo> {
self.published.iter().find(|d| d.id == id).cloned()
}
pub fn connect(&mut self, id: &str) -> Result<DeviceInfo, String> {
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<DeviceInfo, String> {
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<PodId, String> {
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<DeviceInfo> {
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<bool>, tx: watch::Sender<ScanSnapshot>) {
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}");
}
}