Ride the drivetrain, command the load in watts
Speed now comes from the drivetrain and the load from the road, which is the way round a bike actually works. Speed is cadence x development, filtered lightly. Power, not cadence, decides whether the rider is driving it: on a direct-drive trainer the flywheel keeps the cranks turning after they stop, so cadence alone reads a healthy 80 rpm for someone doing nothing. Below 15 W the speed runs down to whatever the gradient sustains on no power - zero uphill, a real freewheeling speed on a descent. Stopping on a 3.5% climb used to settle at 22 km/h and stay there, because the model wanted to decelerate and a blend toward the flywheel speed outvoted it; that blend is gone. The D100 sends no cadence over FTMS - it is a rebadged Magene T110 with cadence disabled in firmware (qdomyos-zwift#3282) - so it is inferred from wheel speed, which one sprocket and no freewheel make exact. Its Zwift channel does carry cadence, and is now greeted with RideOn and subscribed on every notifying characteristic, so a measured value is used where one arrives. The load is commanded as power, not gradient. The trainer declares 50-600 W in 1 W steps against 0-6% inclination in 0.1% steps refusing negatives, and whether it acts on 0x11 at all is still unconfirmed. Its power target is a ceiling rather than a setpoint, which is very nearly what a road is: exceed it and the surplus becomes speed. Gravity travels on the same channel as watts, so nothing is lost by leaving 0x11 alone. LoadChannel keeps the gradient path selectable and tested. Virtual shifting reaches the trainer for the first time. The physics load model was written but never called, and a paddle press both shifted a gear in Rust and nudged the gradient in the webview - the shift silently, the tilt visibly, so the paddles looked like a gradient trim. Also: a fixed 12 W drivetrain loss, held as a power because that is how it presents; crank length, so a gear can be reported as the force it puts under the foot; gear and pedal force on the ride screen; a drag-race profile for testing gearing on the flat. Two readout bugs fixed on the way. The rolling windows were trimmed by timestamp but fed on a fixed timer, so every second spent on the ride screen before starting pushed samples at t=0 that could never expire - speed read a fraction of the truth for the first 45 s. And the headline speed was a 45 s mean, which took most of a minute to show a gear change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+165
-23
@@ -18,16 +18,18 @@
|
||||
//! Controlling`, and it can sit at `Connected` indefinitely if the control
|
||||
//! point is refused.
|
||||
|
||||
use std::collections::HashSet;
|
||||
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
|
||||
@@ -45,13 +47,25 @@ const HEART_RATE_SERVICE: Uuid = Uuid::from_u128(0x0000180d_0000_1000_8000_00805
|
||||
pub enum DeviceKind {
|
||||
/// Advertises FTMS (`0x1826`).
|
||||
Trainer,
|
||||
/// Zwift custom service, manufacturer type byte identifying the left pod.
|
||||
ClickLeft,
|
||||
ClickRight,
|
||||
/// 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 {
|
||||
@@ -70,8 +84,11 @@ pub struct DeviceInfo {
|
||||
/// 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>,
|
||||
// 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>,
|
||||
}
|
||||
@@ -99,6 +116,9 @@ pub struct ScanSnapshot {
|
||||
|
||||
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>,
|
||||
@@ -115,13 +135,14 @@ pub struct DeviceRegistry {
|
||||
}
|
||||
|
||||
impl DeviceRegistry {
|
||||
pub fn new(trainer: TrainerHandle) -> Self {
|
||||
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(),
|
||||
@@ -172,7 +193,11 @@ impl DeviceRegistry {
|
||||
let changed = next != self.published;
|
||||
self.published = next;
|
||||
|
||||
PollResult { changed, transitions, trainer_changed }
|
||||
PollResult {
|
||||
changed,
|
||||
transitions,
|
||||
trainer_changed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge the scan snapshot with the trainer's live status.
|
||||
@@ -180,14 +205,26 @@ impl DeviceRegistry {
|
||||
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: classify(d),
|
||||
kind,
|
||||
name: d.label(),
|
||||
address: d.address.clone(),
|
||||
rssi: d.rssi.unwrap_or(0),
|
||||
@@ -200,7 +237,6 @@ impl DeviceRegistry {
|
||||
services: d.services.iter().map(|u| describe_service(*u)).collect(),
|
||||
remembered: self.remembered.contains(&id),
|
||||
battery_pct: None,
|
||||
unlock_expires_in_s: None,
|
||||
error: None,
|
||||
id,
|
||||
});
|
||||
@@ -224,7 +260,6 @@ impl DeviceRegistry {
|
||||
services: vec![describe_service(uuids::FITNESS_MACHINE_SERVICE)],
|
||||
remembered: true,
|
||||
battery_pct: None,
|
||||
unlock_expires_in_s: None,
|
||||
error: None,
|
||||
});
|
||||
out.len() - 1
|
||||
@@ -245,6 +280,60 @@ impl DeviceRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
// 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| {
|
||||
@@ -268,9 +357,24 @@ impl DeviceRegistry {
|
||||
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 not a trainer. Zwift Click support is Phase 3 (REQUIREMENTS.md §5.3).",
|
||||
"{} is neither a trainer nor a Click pod — there is nothing to connect to.",
|
||||
device.name
|
||||
));
|
||||
}
|
||||
@@ -323,13 +427,18 @@ impl DeviceRegistry {
|
||||
// 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}"))?;
|
||||
let device = self
|
||||
.get(id)
|
||||
.ok_or_else(|| format!("no such device: {id}"))?;
|
||||
if device.kind == DeviceKind::Trainer && device.control_acquired {
|
||||
self.trainer.disconnect();
|
||||
}
|
||||
@@ -339,6 +448,23 @@ impl DeviceRegistry {
|
||||
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 {
|
||||
@@ -359,12 +485,17 @@ fn classify(d: &DiscoveredDevice) -> DeviceKind {
|
||||
if d.services.contains(&HEART_RATE_SERVICE) {
|
||||
return DeviceKind::HeartRate;
|
||||
}
|
||||
if d.services.contains(&ZWIFT_SERVICE) || d.is_zwift_device() {
|
||||
// Splitting left pod from right needs the Zwift manufacturer-data type
|
||||
// byte, which is Phase 3 and unverified against this hardware. Guessing
|
||||
// would put a wrong label on the connection screen, so it stays Unknown
|
||||
// and the service UUID is listed instead.
|
||||
return DeviceKind::Unknown;
|
||||
// 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
|
||||
}
|
||||
@@ -433,7 +564,11 @@ async fn scan_loop(mut on: watch::Receiver<bool>, tx: watch::Sender<ScanSnapshot
|
||||
let result = scan::scan(&adapter, SCAN_WINDOW, ScanKind::All).await;
|
||||
generation += 1;
|
||||
let snapshot = match result {
|
||||
Ok(devices) => ScanSnapshot { devices, error: None, generation },
|
||||
Ok(devices) => ScanSnapshot {
|
||||
devices,
|
||||
error: None,
|
||||
generation,
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "scan failed");
|
||||
ScanSnapshot {
|
||||
@@ -464,7 +599,6 @@ mod tests {
|
||||
services: Vec::new(),
|
||||
remembered: false,
|
||||
battery_pct: None,
|
||||
unlock_expires_in_s: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
@@ -497,7 +631,13 @@ mod tests {
|
||||
#[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 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);
|
||||
@@ -517,7 +657,9 @@ mod tests {
|
||||
// list frozen on a snapshot taken before the attempt.
|
||||
let idle = TrainerStatus::default();
|
||||
let lost = TrainerStatus {
|
||||
state: ConnectionState::Lost { reason: "gone".into() },
|
||||
state: ConnectionState::Lost {
|
||||
reason: "gone".into(),
|
||||
},
|
||||
..TrainerStatus::default()
|
||||
};
|
||||
assert!(should_resume_scan(true, &idle));
|
||||
|
||||
Reference in New Issue
Block a user