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>
737 lines
29 KiB
Rust
737 lines
29 KiB
Rust
//! 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<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}");
|
||
}
|
||
}
|