Core ride logic, FTMS client, FIT encoder and probe CLI

Adds backing state for Resistance and Erg control modes, which had no
value to hold and so could never satisfy FR-4.3/FR-4.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 13:34:27 +02:00
co-authored by Claude Opus 5
parent 3e106de2c5
commit 7c17ca6158
61 changed files with 20933 additions and 55 deletions
+333
View File
@@ -0,0 +1,333 @@
//! Device discovery and connection state (FR-1, FR-9.19.3).
//!
//! `crates/ble` is not written yet, so this is a **mock scanner**: a scripted
//! set of peripherals that appear over a few seconds, with RSSI that drifts and
//! connection state machines that take realistic time to settle. It exists so
//! the connection screen can be built and judged today.
//!
//! The important behaviour it models — and the reason it is not just a static
//! list — is that **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.
//!
//! Swapping in the real scanner means replacing [`DeviceRegistry::poll`] and
//! the two request methods with `btleplug` calls; the `DeviceInfo` the UI
//! renders does not change.
use std::collections::HashSet;
use bikecontrol_core::types::ConnectionState;
use serde::{Deserialize, Serialize};
/// 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,
/// Zwift custom service, manufacturer type byte identifying the left pod.
ClickLeft,
ClickRight,
HeartRate,
Unknown,
}
#[derive(Debug, Clone, 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).
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>,
/// Zwift unlock validity for Click pods (FR-3.9). `None` for other kinds.
pub unlock_expires_in_s: Option<u64>,
/// 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>,
}
/// A scripted peripheral in the mock environment.
struct Simulated {
info: DeviceInfo,
/// Ticks after scan start before it shows up. Models A-4: the trainer only
/// advertises once you pedal, the Click once you press a button.
appears_after: u32,
/// Ticks remaining in the current transition, and where it lands.
pending: Option<(u32, ConnectionState, bool)>,
visible: bool,
}
pub struct DeviceRegistry {
devices: Vec<Simulated>,
forgotten: HashSet<String>,
pub scanning: bool,
ticks: u32,
rng: u64,
}
/// How long each mock transition takes, in registry ticks (2 Hz).
const CONNECT_TICKS: u32 = 3;
const CONTROL_TICKS: u32 = 3;
impl Default for DeviceRegistry {
fn default() -> Self {
Self::new()
}
}
impl DeviceRegistry {
pub fn new() -> Self {
Self {
devices: catalogue(),
forgotten: HashSet::new(),
scanning: false,
ticks: 0,
rng: 0xDEAD_BEEF_CAFE_F00D,
}
}
fn rand(&mut self) -> f32 {
let mut x = self.rng;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.rng = x;
((x.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 40) as f32) / (1 << 24) as f32
}
pub fn start_scan(&mut self) {
self.scanning = true;
self.ticks = 0;
for d in &mut self.devices {
if !matches!(d.info.state, ConnectionState::Connected | ConnectionState::Controlling) {
d.info.state = ConnectionState::Scanning;
}
}
}
pub fn stop_scan(&mut self) {
self.scanning = false;
for d in &mut self.devices {
if d.info.state == ConnectionState::Scanning {
d.info.state = ConnectionState::Idle;
}
}
}
/// Advance the mock. Reports whether the list changed at all, and which
/// devices crossed a connection-state boundary this tick.
pub fn poll(&mut self) -> PollResult {
let mut changed = false;
let mut transitions = Vec::new();
if self.scanning {
self.ticks += 1;
for i in 0..self.devices.len() {
let appears = self.devices[i].appears_after;
if !self.devices[i].visible && self.ticks >= appears {
self.devices[i].visible = true;
changed = true;
}
if self.devices[i].visible {
let jitter = (self.rand() * 6.0) as i16 - 3;
let base = self.devices[i].info.rssi;
let next = (base + jitter).clamp(-95, -38);
if next != base {
self.devices[i].info.rssi = next;
changed = true;
}
}
}
}
for d in &mut self.devices {
if let Some((remaining, target, control)) = d.pending.take() {
if remaining <= 1 {
d.info.state = target.clone();
d.info.control_acquired = control;
if target == ConnectionState::Connected && d.info.kind == DeviceKind::Trainer {
// Connected, now go after the FTMS control point.
d.pending =
Some((CONTROL_TICKS, ConnectionState::Controlling, true));
}
transitions.push(d.info.clone());
changed = true;
} else {
d.pending = Some((remaining - 1, target, control));
}
}
}
PollResult { changed, transitions }
}
pub fn list(&self) -> Vec<DeviceInfo> {
self.devices
.iter()
.filter(|d| d.visible && !self.forgotten.contains(&d.info.id))
.map(|d| d.info.clone())
.collect()
}
pub fn get(&self, id: &str) -> Option<DeviceInfo> {
self.devices.iter().find(|d| d.info.id == id).map(|d| d.info.clone())
}
pub fn connect(&mut self, id: &str) -> Result<DeviceInfo, String> {
let device = self
.devices
.iter_mut()
.find(|d| d.info.id == id)
.ok_or_else(|| format!("no such device: {id}"))?;
if device.info.state == ConnectionState::Controlling {
return Err(format!("{} is already connected", device.info.name));
}
device.info.error = None;
device.info.state = ConnectionState::Connecting;
device.info.remembered = true;
device.pending = Some((CONNECT_TICKS, ConnectionState::Connected, false));
Ok(device.info.clone())
}
pub fn disconnect(&mut self, id: &str) -> Result<DeviceInfo, String> {
let device = self
.devices
.iter_mut()
.find(|d| d.info.id == id)
.ok_or_else(|| format!("no such device: {id}"))?;
device.pending = None;
device.info.control_acquired = false;
device.info.state = if self.scanning { ConnectionState::Scanning } else { ConnectionState::Idle };
Ok(device.info.clone())
}
pub fn forget(&mut self, id: &str) -> Result<(), String> {
let device = self
.devices
.iter_mut()
.find(|d| d.info.id == id)
.ok_or_else(|| format!("no such device: {id}"))?;
device.pending = None;
device.info.remembered = false;
device.info.control_acquired = false;
device.info.state = ConnectionState::Idle;
device.visible = false;
self.forgotten.insert(id.to_string());
Ok(())
}
/// True once a trainer is connected *and* controllable — the precondition
/// for a real ride (FR-2.1).
pub fn trainer_controllable(&self) -> bool {
self.devices
.iter()
.any(|d| d.info.kind == DeviceKind::Trainer && d.info.control_acquired)
}
}
fn device(
id: &str,
name: &str,
address: &str,
rssi: i16,
kind: DeviceKind,
services: &[&str],
appears_after: u32,
) -> Simulated {
Simulated {
info: DeviceInfo {
id: id.into(),
name: name.into(),
address: address.into(),
rssi,
kind,
state: ConnectionState::Idle,
control_acquired: false,
services: services.iter().map(|s| s.to_string()).collect(),
remembered: false,
battery_pct: match kind {
DeviceKind::ClickLeft => Some(78),
DeviceKind::ClickRight => Some(64),
DeviceKind::HeartRate => Some(91),
_ => None,
},
unlock_expires_in_s: match kind {
DeviceKind::ClickLeft => Some(0),
DeviceKind::ClickRight => Some(41_400),
_ => None,
},
error: None,
},
appears_after,
pending: None,
visible: false,
}
}
/// The mock environment. Timings are in registry ticks (2 Hz), so the trainer
/// takes ~2 s to appear and the pods ~46 s — long enough that the "wake it by
/// pedalling" prompt (FR-1.8) is actually visible.
fn catalogue() -> Vec<Simulated> {
vec![
device(
"d100-1",
"Van Rysel D100",
"E4:2B:11:9A:03:7C",
-54,
DeviceKind::Trainer,
&["0x1826 Fitness Machine", "0x180A Device Information"],
4,
),
device(
"click-l",
"Zwift Click (left)",
"C0:1A:77:12:4E:01",
-63,
DeviceKind::ClickLeft,
&["00000001-19CA-4651-86E5-FA29DCDD09D1"],
9,
),
device(
"click-r",
"Zwift Click (right)",
"C0:1A:77:12:4E:02",
-61,
DeviceKind::ClickRight,
&["00000001-19CA-4651-86E5-FA29DCDD09D1"],
11,
),
device(
"hrm-1",
"Wahoo TICKR",
"D9:44:0B:31:88:2A",
-71,
DeviceKind::HeartRate,
&["0x180D Heart Rate"],
14,
),
device(
"unknown-1",
"(unnamed peripheral)",
"7F:22:C4:08:19:E3",
-88,
DeviceKind::Unknown,
&[],
17,
),
]
}