Virtual gearing, trainer-speed blend, and cadence decode
Gears are expressed as an offset to the commanded gradient, leaving the physics on the route's true gradient so shifting changes effort, not speed. Neutral gear commands exactly the route gradient, so an un-shifted ride is unchanged. Cadence is not in FTMS on this trainer but is on its Zwift channel, decoded against captured frames. The undeclared FTMS trailing bytes were ruled out: wheel RPM restated at a fixed 73.8x speed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,499 @@
|
||||
//! The Zwift Click client: an async actor that owns a controller peripheral.
|
||||
//!
|
||||
//! Structurally a smaller sibling of [`crate::client`]. One task owns the
|
||||
//! peripheral; callers hold a cheap handle and receive [`ClickEvent`]s on a
|
||||
//! broadcast channel.
|
||||
//!
|
||||
//! ```text
|
||||
//! caller ◄──broadcast── ClickEvent ◄── actor ◄─notify── Click pod
|
||||
//! ```
|
||||
//!
|
||||
//! The protocol itself lives in [`crate::zwift`] as pure functions over bytes;
|
||||
//! this module is only the radio and the reconnect loop. See REQUIREMENTS.md
|
||||
//! §2.3.1 for what was confirmed against the hardware — notably that a Click v2
|
||||
//! needs **no encryption**, so there is no key exchange here to get wrong.
|
||||
//!
|
||||
//! Unlike the trainer, a controller has no safety story: it never commands
|
||||
//! load, and the only bytes ever written to it are the `RideOn` handshake.
|
||||
|
||||
use std::future::Future;
|
||||
use std::time::Duration;
|
||||
|
||||
use btleplug::api::{CharPropFlags, Characteristic, Peripheral as _, WriteType};
|
||||
use btleplug::platform::{Adapter, Peripheral};
|
||||
use futures::{Stream, StreamExt};
|
||||
use tokio::sync::{broadcast, mpsc, oneshot};
|
||||
|
||||
use crate::client::{Backoff, InFlight, DISCONNECT_TIMEOUT};
|
||||
use crate::error::FtmsError;
|
||||
use crate::scan::{self, TrainerSelector};
|
||||
use crate::zwift::{self, Button, ButtonTracker};
|
||||
|
||||
/// Tunables for [`ClickClient`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClickConfig {
|
||||
/// How long to look for the pod before giving up. A Click sleeps quickly
|
||||
/// and only advertises after a button press (A-4), so this is generous.
|
||||
pub scan_timeout: Duration,
|
||||
pub backoff: Backoff,
|
||||
pub channel_capacity: usize,
|
||||
}
|
||||
|
||||
impl Default for ClickConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
scan_timeout: Duration::from_secs(20),
|
||||
backoff: Backoff::default(),
|
||||
channel_capacity: 64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the app learns from a controller.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ClickEvent {
|
||||
Connected {
|
||||
address: String,
|
||||
name: Option<String>,
|
||||
},
|
||||
/// The link dropped. Any held button has already been reported as released.
|
||||
Disconnected,
|
||||
/// A press or release edge. Repeats while held are filtered out here, not
|
||||
/// by the consumer (see [`ButtonTracker`]).
|
||||
Button { button: Button, pressed: bool },
|
||||
Battery { percent: u8 },
|
||||
/// A frame we could not interpret. Surfaced rather than dropped: the
|
||||
/// protocol is only partly documented, and silence would hide the parts we
|
||||
/// have not met yet.
|
||||
Unknown { kind: u8, raw: Vec<u8> },
|
||||
}
|
||||
|
||||
enum Cmd {
|
||||
Shutdown { reply: oneshot::Sender<()> },
|
||||
}
|
||||
|
||||
/// Cheap, cloneable handle to a controller session.
|
||||
#[derive(Clone)]
|
||||
pub struct ClickClient {
|
||||
cmd_tx: mpsc::Sender<Cmd>,
|
||||
events_tx: broadcast::Sender<ClickEvent>,
|
||||
}
|
||||
|
||||
impl ClickClient {
|
||||
/// Connect to a Click and start streaming events.
|
||||
///
|
||||
/// Returns once the pod has answered the handshake, so a caller that gets
|
||||
/// an `Ok` knows the controller is genuinely talking — not merely that a
|
||||
/// BLE link exists.
|
||||
pub async fn connect(
|
||||
selector: TrainerSelector,
|
||||
config: ClickConfig,
|
||||
) -> Result<Self, FtmsError> {
|
||||
let adapter = scan::default_adapter().await?;
|
||||
Self::connect_with_adapter(adapter, selector, config).await
|
||||
}
|
||||
|
||||
/// As [`ClickClient::connect`], but abandoned as soon as `cancel` resolves.
|
||||
///
|
||||
/// Returns `Ok(None)` when it was cancelled. A Click only advertises after a
|
||||
/// button press (A-4), so a connect routinely runs the full `scan_timeout` —
|
||||
/// twenty seconds, which is long enough that a quit waiting for it reads as
|
||||
/// a hang (FR-1.10). Any link the abandoned attempt had opened is closed
|
||||
/// before this returns (SAF-9).
|
||||
pub async fn connect_cancellable(
|
||||
selector: TrainerSelector,
|
||||
config: ClickConfig,
|
||||
cancel: impl Future<Output = ()>,
|
||||
) -> Result<Option<Self>, FtmsError> {
|
||||
let in_flight = InFlight::default();
|
||||
let outcome = {
|
||||
let attempt = async {
|
||||
let adapter = scan::default_adapter().await?;
|
||||
Self::connect_on(adapter, selector, config, &in_flight).await
|
||||
};
|
||||
tokio::pin!(attempt, cancel);
|
||||
tokio::select! {
|
||||
result = &mut attempt => Some(result),
|
||||
() = &mut cancel => None,
|
||||
}
|
||||
};
|
||||
match outcome {
|
||||
Some(result) => result.map(Some),
|
||||
None => {
|
||||
tracing::info!("click: connect attempt cancelled");
|
||||
in_flight.abandon().await;
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// As [`ClickClient::connect`], but on a caller-supplied adapter.
|
||||
pub async fn connect_with_adapter(
|
||||
adapter: Adapter,
|
||||
selector: TrainerSelector,
|
||||
config: ClickConfig,
|
||||
) -> Result<Self, FtmsError> {
|
||||
Self::connect_on(adapter, selector, config, &InFlight::default()).await
|
||||
}
|
||||
|
||||
async fn connect_on(
|
||||
adapter: Adapter,
|
||||
selector: TrainerSelector,
|
||||
config: ClickConfig,
|
||||
in_flight: &InFlight,
|
||||
) -> Result<Self, FtmsError> {
|
||||
let (events_tx, _) = broadcast::channel(config.channel_capacity);
|
||||
let (cmd_tx, cmd_rx) = mpsc::channel(8);
|
||||
|
||||
let (session, notifications) = open_session(&adapter, &selector, &config, in_flight).await?;
|
||||
let _ = events_tx.send(ClickEvent::Connected {
|
||||
address: session.address.clone(),
|
||||
name: session.name.clone(),
|
||||
});
|
||||
|
||||
let actor = Actor {
|
||||
adapter,
|
||||
selector,
|
||||
config,
|
||||
events_tx: events_tx.clone(),
|
||||
tracker: ButtonTracker::new(),
|
||||
};
|
||||
tokio::spawn(actor.run(cmd_rx, session, Box::pin(notifications)));
|
||||
|
||||
Ok(Self { cmd_tx, events_tx })
|
||||
}
|
||||
|
||||
/// Subscribe to controller events. Late subscribers see only what arrives
|
||||
/// after they subscribe.
|
||||
pub fn events(&self) -> broadcast::Receiver<ClickEvent> {
|
||||
self.events_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Close the link, and wait for it to actually be closed.
|
||||
///
|
||||
/// SAF-9: a pod whose link the process merely abandons can stay held by
|
||||
/// BlueZ and be unreachable on the next launch. Fire-and-forget would return
|
||||
/// before the disconnect had even been issued, which at app exit means it
|
||||
/// never is. Idempotent — shutting down a stopped client is a no-op.
|
||||
pub async fn shutdown(&self) {
|
||||
let (reply, done) = oneshot::channel();
|
||||
if self.cmd_tx.send(Cmd::Shutdown { reply }).await.is_err() {
|
||||
// The actor is already gone; it tore the link down on its way out.
|
||||
return;
|
||||
}
|
||||
let _ = done.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Boxed so that the initial connection and every reconnect share one type —
|
||||
/// `impl Stream` would mint a fresh opaque type per call site and the two could
|
||||
/// not be assigned to the same variable.
|
||||
type Notifications = std::pin::Pin<
|
||||
Box<dyn Stream<Item = btleplug::api::ValueNotification> + Send>,
|
||||
>;
|
||||
|
||||
/// A live connection: the peripheral plus the characteristics we care about.
|
||||
struct Session {
|
||||
peripheral: Peripheral,
|
||||
address: String,
|
||||
name: Option<String>,
|
||||
subscribed: Vec<Characteristic>,
|
||||
}
|
||||
|
||||
/// Find the pod, connect, subscribe, and complete the `RideOn` handshake.
|
||||
async fn open_session(
|
||||
adapter: &Adapter,
|
||||
selector: &TrainerSelector,
|
||||
config: &ClickConfig,
|
||||
in_flight: &InFlight,
|
||||
) -> Result<(Session, Notifications), FtmsError> {
|
||||
let peripheral = scan::find_peripheral(adapter, selector, config.scan_timeout).await?;
|
||||
// Cancelling past this point would otherwise strand the link (FR-1.10).
|
||||
in_flight.hold(peripheral.clone());
|
||||
|
||||
match setup_session(peripheral.clone()).await {
|
||||
Ok(session) => {
|
||||
in_flight.released();
|
||||
Ok(session)
|
||||
}
|
||||
Err(e) => {
|
||||
// Every failure after `connect()` leaves a live GATT link behind,
|
||||
// and a pod that is still held will not advertise for the next
|
||||
// attempt — so the retry loop would never succeed.
|
||||
tracing::debug!(error = %e, "click: session setup failed; disconnecting");
|
||||
let _ = peripheral.disconnect().await;
|
||||
in_flight.released();
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn setup_session(peripheral: Peripheral) -> Result<(Session, Notifications), FtmsError> {
|
||||
if !peripheral.is_connected().await.unwrap_or(false) {
|
||||
peripheral.connect().await?;
|
||||
}
|
||||
peripheral.discover_services().await?;
|
||||
|
||||
// A Click v2 carries 0xFC82; the trainer carries 00000001-19CA-…. Both hold
|
||||
// the same characteristics, so take whichever is present.
|
||||
let service = zwift::SERVICES
|
||||
.iter()
|
||||
.find_map(|want| peripheral.services().into_iter().find(|s| s.uuid == *want))
|
||||
.ok_or(FtmsError::MissingCharacteristic("Zwift custom service"))?;
|
||||
|
||||
let notifications = peripheral.notifications().await?;
|
||||
|
||||
// Subscribe before writing, so the handshake reply cannot outrun us.
|
||||
let mut subscribed = Vec::new();
|
||||
for ch in &service.characteristics {
|
||||
if ch
|
||||
.properties
|
||||
.intersects(CharPropFlags::NOTIFY | CharPropFlags::INDICATE)
|
||||
{
|
||||
match peripheral.subscribe(ch).await {
|
||||
Ok(()) => subscribed.push(ch.clone()),
|
||||
Err(e) => tracing::debug!(uuid = %ch.uuid, error = %e, "click: subscribe failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
if subscribed.is_empty() {
|
||||
return Err(FtmsError::MissingCharacteristic(
|
||||
"a notifying Zwift characteristic",
|
||||
));
|
||||
}
|
||||
|
||||
let sync_rx = service
|
||||
.characteristics
|
||||
.iter()
|
||||
.find(|c| c.uuid == zwift::SYNC_RX)
|
||||
.ok_or(FtmsError::MissingCharacteristic("Zwift sync RX"))?;
|
||||
let write_type = if sync_rx
|
||||
.properties
|
||||
.contains(CharPropFlags::WRITE_WITHOUT_RESPONSE)
|
||||
{
|
||||
WriteType::WithoutResponse
|
||||
} else {
|
||||
WriteType::WithResponse
|
||||
};
|
||||
peripheral
|
||||
.write(sync_rx, &zwift::handshake(&zwift::REQUEST_START), write_type)
|
||||
.await?;
|
||||
|
||||
let described = scan::describe(&peripheral).await;
|
||||
Ok((
|
||||
Session {
|
||||
address: described
|
||||
.as_ref()
|
||||
.map(|d| d.address.clone())
|
||||
.unwrap_or_default(),
|
||||
name: described.and_then(|d| d.name),
|
||||
peripheral,
|
||||
subscribed,
|
||||
},
|
||||
Box::pin(notifications),
|
||||
))
|
||||
}
|
||||
|
||||
struct Actor {
|
||||
adapter: Adapter,
|
||||
selector: TrainerSelector,
|
||||
config: ClickConfig,
|
||||
events_tx: broadcast::Sender<ClickEvent>,
|
||||
tracker: ButtonTracker,
|
||||
}
|
||||
|
||||
impl Actor {
|
||||
async fn run(
|
||||
mut self,
|
||||
mut cmd_rx: mpsc::Receiver<Cmd>,
|
||||
mut current: Session,
|
||||
mut notifications: Notifications,
|
||||
) {
|
||||
let mut attempt = 0u32;
|
||||
|
||||
loop {
|
||||
let dropped = tokio::select! {
|
||||
cmd = cmd_rx.recv() => {
|
||||
match cmd {
|
||||
Some(Cmd::Shutdown { reply }) => {
|
||||
self.teardown(¤t).await;
|
||||
// Answered only once the link is genuinely closed,
|
||||
// so a caller blocking the app's exit on this knows
|
||||
// what it waited for (SAF-9).
|
||||
let _ = reply.send(());
|
||||
return;
|
||||
}
|
||||
None => {
|
||||
self.teardown(¤t).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
frame = notifications.next() => match frame {
|
||||
Some(n) => { self.handle(&n.value); false }
|
||||
// The stream ending is how btleplug reports a dropped link.
|
||||
None => true,
|
||||
},
|
||||
};
|
||||
|
||||
if !dropped {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Release anything still held, so a paddle held through a dropout
|
||||
// cannot latch (see ButtonTracker::reset).
|
||||
self.release_held();
|
||||
let _ = self.events_tx.send(ClickEvent::Disconnected);
|
||||
|
||||
// FR-1.6: reconnect with backoff. A Click sleeps aggressively, so
|
||||
// "not found" is the normal case, not an error worth giving up on.
|
||||
loop {
|
||||
if self.config.backoff.exhausted(attempt) {
|
||||
tracing::warn!("click: giving up after {attempt} reconnect attempts");
|
||||
return;
|
||||
}
|
||||
let delay = self.config.backoff.delay(attempt);
|
||||
attempt += 1;
|
||||
|
||||
// Both the backoff *and* the attempt stay answerable to
|
||||
// shutdown. `open_session` runs for as long as `scan_timeout`
|
||||
// — twenty seconds against a pod that only advertises after a
|
||||
// button press — and a quit that waits for that is a quit that
|
||||
// leaves the link open (FR-1.10, SAF-9).
|
||||
let in_flight = InFlight::default();
|
||||
let outcome = {
|
||||
let attempting = async {
|
||||
tokio::time::sleep(delay).await;
|
||||
open_session(&self.adapter, &self.selector, &self.config, &in_flight).await
|
||||
};
|
||||
tokio::pin!(attempting);
|
||||
tokio::select! {
|
||||
biased;
|
||||
cmd = cmd_rx.recv() => Err(cmd),
|
||||
result = &mut attempting => Ok(result),
|
||||
}
|
||||
};
|
||||
|
||||
let result = match outcome {
|
||||
Ok(result) => result,
|
||||
Err(cmd) => {
|
||||
// There is no session to tear down — the attempt owns
|
||||
// whatever link exists, so closing that is the whole job.
|
||||
in_flight.abandon().await;
|
||||
if let Some(Cmd::Shutdown { reply }) = cmd {
|
||||
let _ = reply.send(());
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok((session, stream)) => {
|
||||
let _ = self.events_tx.send(ClickEvent::Connected {
|
||||
address: session.address.clone(),
|
||||
name: session.name.clone(),
|
||||
});
|
||||
current = session;
|
||||
notifications = stream;
|
||||
attempt = 0;
|
||||
break;
|
||||
}
|
||||
Err(e) => tracing::debug!(error = %e, "click: reconnect failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode one notification into events.
|
||||
fn handle(&mut self, raw: &[u8]) {
|
||||
if zwift::is_ride_on_reply(raw) {
|
||||
tracing::debug!("click: RideOn acknowledged");
|
||||
return;
|
||||
}
|
||||
let Some(frame) = zwift::parse_frame(raw) else {
|
||||
return;
|
||||
};
|
||||
|
||||
match frame.kind {
|
||||
zwift::MessageType::ButtonBitmask => {
|
||||
match zwift::decode_button_bitmask(frame.payload) {
|
||||
Ok(mask) => {
|
||||
for edge in self.tracker.update(mask) {
|
||||
let _ = self.events_tx.send(ClickEvent::Button {
|
||||
button: edge.button,
|
||||
pressed: edge.pressed,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::debug!(error = %e, "click: bad button frame"),
|
||||
}
|
||||
}
|
||||
zwift::MessageType::Battery => {
|
||||
if let Ok(Some(percent)) = zwift::decode_battery(frame.payload) {
|
||||
let _ = self.events_tx.send(ClickEvent::Battery { percent });
|
||||
}
|
||||
}
|
||||
zwift::MessageType::KeepAlive => {}
|
||||
zwift::MessageType::Unknown(kind) => {
|
||||
let _ = self.events_tx.send(ClickEvent::Unknown {
|
||||
kind,
|
||||
raw: raw.to_vec(),
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit a release for every button the tracker still believes is held.
|
||||
fn release_held(&mut self) {
|
||||
for button in self.tracker.held() {
|
||||
let _ = self.events_tx.send(ClickEvent::Button {
|
||||
button,
|
||||
pressed: false,
|
||||
});
|
||||
}
|
||||
self.tracker.reset();
|
||||
}
|
||||
|
||||
/// Close the link. Every step is bounded: this runs on the app's exit path,
|
||||
/// where a radio operation that never returns is a window that never shuts
|
||||
/// (NFR-9).
|
||||
async fn teardown(&mut self, session: &Session) {
|
||||
self.release_held();
|
||||
for ch in &session.subscribed {
|
||||
let unsubscribe = session.peripheral.unsubscribe(ch);
|
||||
let _ = tokio::time::timeout(DISCONNECT_TIMEOUT, unsubscribe).await;
|
||||
}
|
||||
match tokio::time::timeout(DISCONNECT_TIMEOUT, session.peripheral.disconnect()).await {
|
||||
Ok(Ok(())) => tracing::info!("click: disconnected"),
|
||||
Ok(Err(e)) => tracing::debug!(error = %e, "click: disconnect failed"),
|
||||
Err(_) => tracing::warn!("click: disconnect timed out"),
|
||||
}
|
||||
let _ = self.events_tx.send(ClickEvent::Disconnected);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn config_defaults_are_patient_enough_for_a_sleeping_pod() {
|
||||
let c = ClickConfig::default();
|
||||
assert!(c.scan_timeout >= Duration::from_secs(10));
|
||||
// Retry forever: a Click that has gone to sleep is the normal case.
|
||||
assert_eq!(c.backoff.max_attempts, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn events_compare_by_value() {
|
||||
assert_eq!(
|
||||
ClickEvent::Button { button: Button::Plus, pressed: true },
|
||||
ClickEvent::Button { button: Button::Plus, pressed: true }
|
||||
);
|
||||
assert_ne!(
|
||||
ClickEvent::Button { button: Button::Plus, pressed: true },
|
||||
ClickEvent::Button { button: Button::Plus, pressed: false }
|
||||
);
|
||||
}
|
||||
}
|
||||
+239
-13
@@ -17,6 +17,8 @@
|
||||
//! MIT licensed, Copyright (c) 2025 Ogi. See REQUIREMENTS.md §3.2.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use bikecontrol_core::types::{ConnectionState, ControlTarget, SafetyLimits, Telemetry};
|
||||
@@ -232,11 +234,55 @@ impl FtmsClient {
|
||||
Self::connect_with_adapter(adapter, selector, config).await
|
||||
}
|
||||
|
||||
/// As [`FtmsClient::connect`], but abandoned as soon as `cancel` resolves.
|
||||
///
|
||||
/// Returns `Ok(None)` when it was cancelled. A connect runs for up to
|
||||
/// [`FtmsConfig::scan_timeout`] before the trainer has even been found, and
|
||||
/// a caller that needs to stop — the app is quitting, the rider pressed
|
||||
/// disconnect — cannot wait that out (FR-1.10, SAF-8). Any link the
|
||||
/// abandoned attempt had already opened is closed before this returns, so
|
||||
/// the trainer is free for the next launch (A-3).
|
||||
pub async fn connect_cancellable(
|
||||
selector: TrainerSelector,
|
||||
config: FtmsConfig,
|
||||
cancel: impl Future<Output = ()>,
|
||||
) -> Result<Option<Self>, FtmsError> {
|
||||
let in_flight = InFlight::default();
|
||||
let outcome = {
|
||||
let attempt = async {
|
||||
let adapter = scan::default_adapter().await?;
|
||||
Self::connect_on(adapter, selector, config, &in_flight).await
|
||||
};
|
||||
tokio::pin!(attempt, cancel);
|
||||
tokio::select! {
|
||||
result = &mut attempt => Some(result),
|
||||
() = &mut cancel => None,
|
||||
}
|
||||
};
|
||||
match outcome {
|
||||
Some(result) => result.map(Some),
|
||||
None => {
|
||||
tracing::info!("connect attempt cancelled");
|
||||
in_flight.abandon().await;
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// As [`FtmsClient::connect`], but on a caller-supplied adapter.
|
||||
pub async fn connect_with_adapter(
|
||||
adapter: Adapter,
|
||||
selector: TrainerSelector,
|
||||
config: FtmsConfig,
|
||||
) -> Result<Self, FtmsError> {
|
||||
Self::connect_on(adapter, selector, config, &InFlight::default()).await
|
||||
}
|
||||
|
||||
async fn connect_on(
|
||||
adapter: Adapter,
|
||||
selector: TrainerSelector,
|
||||
config: FtmsConfig,
|
||||
in_flight: &InFlight,
|
||||
) -> Result<Self, FtmsError> {
|
||||
let (state_tx, state_rx) = watch::channel(ConnectionState::Scanning);
|
||||
let (caps_tx, caps_rx) = watch::channel(TrainerCapabilities::default());
|
||||
@@ -244,7 +290,7 @@ impl FtmsClient {
|
||||
let (events_tx, _) = broadcast::channel(config.channel_capacity);
|
||||
let (cmd_tx, cmd_rx) = mpsc::channel(64);
|
||||
|
||||
let connected = connect_session(&adapter, &selector, &config, &state_tx).await?;
|
||||
let connected = connect_session(&adapter, &selector, &config, &state_tx, in_flight).await?;
|
||||
|
||||
let address = connected.address.clone();
|
||||
let name = connected.name.clone();
|
||||
@@ -385,6 +431,16 @@ impl FtmsClient {
|
||||
/// on application exit: it is the difference between "the trainer is left
|
||||
/// at zero" and "probably".
|
||||
pub async fn shutdown(self) -> Result<(), FtmsError> {
|
||||
self.shutdown_ref().await
|
||||
}
|
||||
|
||||
/// As [`FtmsClient::shutdown`], but by reference.
|
||||
///
|
||||
/// A caller that shares the client — an `Arc<FtmsClient>` behind a
|
||||
/// supervisor, so control writes can be spawned without stalling the
|
||||
/// telemetry pump — cannot consume it, and SAF-2 must not depend on being
|
||||
/// able to. Idempotent: shutting an already-stopped client down is a no-op.
|
||||
pub async fn shutdown_ref(&self) -> Result<(), FtmsError> {
|
||||
let (reply, rx) = oneshot::channel();
|
||||
if self
|
||||
.cmd_tx
|
||||
@@ -419,6 +475,60 @@ enum Command {
|
||||
},
|
||||
}
|
||||
|
||||
/// Upper bound on one write in the SAF-2 reset sequence.
|
||||
const SAFETY_WRITE_TIMEOUT: Duration = Duration::from_millis(750);
|
||||
/// Total budget for the SAF-2 *writes*, leaving room inside NFR-9's eight
|
||||
/// seconds for the disconnect that follows. Individually bounded writes are not
|
||||
/// enough on their own: five of them plus their pacing can still outlast a
|
||||
/// caller holding the app's exit open. The sequence zeroes the active targets
|
||||
/// first (see [`safety_reset_commands`]), so a truncated run has still left the
|
||||
/// trainer at minimum load — which is the part SAF-2 actually cares about.
|
||||
const SAFETY_SEQUENCE_BUDGET: Duration = Duration::from_secs(4);
|
||||
/// Upper bound on closing a link, whether deliberately or after a cancelled
|
||||
/// attempt. A disconnect that hangs must not be the reason the app will not
|
||||
/// quit (NFR-9).
|
||||
pub(crate) const DISCONNECT_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
/// The peripheral a connect attempt is working on right now.
|
||||
///
|
||||
/// A cancelled attempt (FR-1.10) may already have opened a GATT link, and a
|
||||
/// cancelled future cannot hand anything back to its caller — hence the shared
|
||||
/// slot. Walking away from that link without closing it matters because the
|
||||
/// D100 accepts one host (A-3): the difference is between "quit and relaunch
|
||||
/// fixed it" and "power-cycle the trainer".
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct InFlight(Arc<std::sync::Mutex<Option<Peripheral>>>);
|
||||
|
||||
impl InFlight {
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, Option<Peripheral>> {
|
||||
self.0.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Take responsibility for a link the attempt is about to open.
|
||||
pub(crate) fn hold(&self, peripheral: Peripheral) {
|
||||
*self.lock() = Some(peripheral);
|
||||
}
|
||||
|
||||
/// The attempt finished and its outcome owns the link now — successfully,
|
||||
/// or by having disconnected on its own error path.
|
||||
pub(crate) fn released(&self) {
|
||||
let _ = self.lock().take();
|
||||
}
|
||||
|
||||
/// Close a link left half-open by a cancelled attempt (SAF-8).
|
||||
pub(crate) async fn abandon(&self) {
|
||||
let Some(peripheral) = self.lock().take() else {
|
||||
return;
|
||||
};
|
||||
tracing::info!("closing the half-open link from a cancelled connect attempt");
|
||||
match tokio::time::timeout(DISCONNECT_TIMEOUT, peripheral.disconnect()).await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => tracing::warn!(error = %e, "abandoned link would not close"),
|
||||
Err(_) => tracing::warn!("abandoned link did not close in time"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The characteristics we need on a connected peripheral.
|
||||
struct Session {
|
||||
peripheral: Peripheral,
|
||||
@@ -884,8 +994,49 @@ impl Actor {
|
||||
|
||||
attempt += 1;
|
||||
self.set_state(ConnectionState::Connecting);
|
||||
match connect_session(&self.adapter, &self.selector, &self.config, &self.state_tx).await
|
||||
{
|
||||
|
||||
// The *attempt* has to be interruptible too, not just the backoff
|
||||
// sleep before it. It runs for up to `scan_timeout` plus connect,
|
||||
// discovery and handshake — far longer than the caller's shutdown
|
||||
// budget — and a shutdown that waits for it to finish is a shutdown
|
||||
// that skips SAF-2 (FR-1.10, SAF-8).
|
||||
let in_flight = InFlight::default();
|
||||
let outcome = {
|
||||
let connect = connect_session(
|
||||
&self.adapter,
|
||||
&self.selector,
|
||||
&self.config,
|
||||
&self.state_tx,
|
||||
&in_flight,
|
||||
);
|
||||
tokio::pin!(connect);
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
cmd = cmd_rx.recv() => match cmd {
|
||||
Some(Command::Shutdown { reply }) => break Attempt::Abandoned(Some(reply)),
|
||||
Some(Command::SetTarget { reply, .. }) => {
|
||||
let _ = reply.send(Err(FtmsError::NotConnected));
|
||||
}
|
||||
Some(Command::Procedure { reply, .. }) => {
|
||||
let _ = reply.send(Err(FtmsError::NotConnected));
|
||||
}
|
||||
None => break Attempt::Abandoned(None),
|
||||
},
|
||||
result = &mut connect => break Attempt::Finished(Box::new(result)),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let result = match outcome {
|
||||
Attempt::Finished(result) => *result,
|
||||
Attempt::Abandoned(reply) => {
|
||||
in_flight.abandon().await;
|
||||
return Reconnected::Shutdown(reply);
|
||||
}
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(connected) => {
|
||||
self.session = Some(connected.session);
|
||||
self.capabilities = connected.capabilities;
|
||||
@@ -918,23 +1069,36 @@ impl Actor {
|
||||
}
|
||||
|
||||
let Some(session) = self.session.take() else {
|
||||
self.set_state(ConnectionState::Idle);
|
||||
// FR-1.11: a give-up has already published `Lost` with the reason
|
||||
// it gave up for. `Idle` here would erase that and read as "never
|
||||
// connected", which is the one thing the rider must not be told
|
||||
// after the app spent minutes trying to get back.
|
||||
if !matches!(*self.state_tx.borrow(), ConnectionState::Lost { .. }) {
|
||||
self.set_state(ConnectionState::Idle);
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
tracing::info!("running the SAF-2 shutdown sequence");
|
||||
let deadline = Instant::now() + SAFETY_SEQUENCE_BUDGET;
|
||||
for (op, bytes) in safety_reset_commands(
|
||||
&self.config.limits,
|
||||
&self.capabilities,
|
||||
self.config.use_simulation_mode,
|
||||
self.config.simulation_template,
|
||||
) {
|
||||
if Instant::now() >= deadline {
|
||||
// The zeroing writes go first, so what is being skipped here is
|
||||
// `Reset`/`Stop` on a trainer that is already at minimum load.
|
||||
tracing::warn!(%op, "SAF-2 budget spent; skipping the rest of the reset sequence");
|
||||
break;
|
||||
}
|
||||
let write = session.peripheral.write(
|
||||
&session.control_point,
|
||||
&bytes,
|
||||
WriteType::WithResponse,
|
||||
);
|
||||
match tokio::time::timeout(Duration::from_millis(750), write).await {
|
||||
match tokio::time::timeout(SAFETY_WRITE_TIMEOUT, write).await {
|
||||
Ok(Ok(())) => tracing::debug!(%op, raw = %hex(&bytes), "shutdown write"),
|
||||
Ok(Err(e)) => tracing::warn!(%op, error = %e, "shutdown write failed"),
|
||||
Err(_) => tracing::warn!(%op, "shutdown write timed out"),
|
||||
@@ -943,7 +1107,7 @@ impl Actor {
|
||||
tokio::time::sleep(Duration::from_millis(60)).await;
|
||||
}
|
||||
|
||||
match tokio::time::timeout(Duration::from_secs(2), session.peripheral.disconnect()).await {
|
||||
match tokio::time::timeout(DISCONNECT_TIMEOUT, session.peripheral.disconnect()).await {
|
||||
Ok(Ok(())) => tracing::info!("disconnected from the trainer"),
|
||||
Ok(Err(e)) => tracing::warn!(error = %e, "disconnect failed"),
|
||||
Err(_) => tracing::warn!("disconnect timed out"),
|
||||
@@ -981,6 +1145,15 @@ enum Reconnected {
|
||||
GaveUp,
|
||||
}
|
||||
|
||||
/// How one reconnect attempt ended: on its own, or because the caller stopped
|
||||
/// waiting for it (FR-1.10).
|
||||
enum Attempt {
|
||||
/// Boxed only to keep the two variants a similar size; a `ConnectedTrainer`
|
||||
/// carries the whole session.
|
||||
Finished(Box<Result<ConnectedTrainer, FtmsError>>),
|
||||
Abandoned(Option<oneshot::Sender<()>>),
|
||||
}
|
||||
|
||||
fn complete(reply: Reply, result: Result<(), FtmsError>) {
|
||||
match reply {
|
||||
Reply::Target { sent, tx } => {
|
||||
@@ -1071,6 +1244,7 @@ async fn connect_session(
|
||||
selector: &TrainerSelector,
|
||||
config: &FtmsConfig,
|
||||
state_tx: &watch::Sender<ConnectionState>,
|
||||
in_flight: &InFlight,
|
||||
) -> Result<ConnectedTrainer, FtmsError> {
|
||||
let _ = state_tx.send_if_modified(|s| {
|
||||
if *s == ConnectionState::Scanning {
|
||||
@@ -1082,6 +1256,11 @@ async fn connect_session(
|
||||
});
|
||||
|
||||
let peripheral = scan::find_peripheral(adapter, selector, config.scan_timeout).await?;
|
||||
// From here until this function returns, cancelling the caller is the only
|
||||
// thing that can leave a link open with nobody to close it. Hand the
|
||||
// peripheral over now, before `connect()` — a cancellation lands wherever it
|
||||
// lands, including halfway through the connect itself (FR-1.10).
|
||||
in_flight.hold(peripheral.clone());
|
||||
let described = scan::describe(&peripheral).await;
|
||||
let address = described
|
||||
.as_ref()
|
||||
@@ -1099,16 +1278,20 @@ async fn connect_session(
|
||||
// that accepts one connection at a time (A-3) would stay unavailable to the
|
||||
// next attempt. Set it up separately so every error path disconnects.
|
||||
match setup_session(peripheral.clone(), config, state_tx).await {
|
||||
Ok((session, capabilities, notifications)) => Ok(ConnectedTrainer {
|
||||
session,
|
||||
capabilities,
|
||||
notifications,
|
||||
address,
|
||||
name,
|
||||
}),
|
||||
Ok((session, capabilities, notifications)) => {
|
||||
in_flight.released();
|
||||
Ok(ConnectedTrainer {
|
||||
session,
|
||||
capabilities,
|
||||
notifications,
|
||||
address,
|
||||
name,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "connection setup failed; disconnecting");
|
||||
let _ = peripheral.disconnect().await;
|
||||
in_flight.released();
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
@@ -1604,6 +1787,49 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shutdown_sequence_cannot_outlast_the_apps_exit() {
|
||||
// SAF-8 / NFR-9. Per-write timeouts alone are not a bound: five of them
|
||||
// plus their pacing and the disconnect can still outlast the caller
|
||||
// holding the window open, and a caller that gives up waiting leaves the
|
||||
// trainer loaded. Hence a deadline across the whole sequence.
|
||||
assert!(SAFETY_WRITE_TIMEOUT < SAFETY_SEQUENCE_BUDGET);
|
||||
assert!(SAFETY_SEQUENCE_BUDGET + DISCONNECT_TIMEOUT < Duration::from_secs(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_shutdown_sequence_has_still_zeroed_the_load() {
|
||||
// What the budget above may cut short is the tail. That is only
|
||||
// acceptable because the zeroing writes come first — the trainer is at
|
||||
// minimum load before `Reset` and `Stop` are even attempted, which is
|
||||
// the part SAF-2 exists for.
|
||||
for use_sim in [false, true] {
|
||||
let cmds = safety_reset_commands(
|
||||
&SafetyLimits::default(),
|
||||
&TrainerCapabilities::default(),
|
||||
use_sim,
|
||||
SimulationParameters::default(),
|
||||
);
|
||||
let ops: Vec<OpCode> = cmds.iter().map(|(op, _)| *op).collect();
|
||||
let first_zeroing = ops
|
||||
.iter()
|
||||
.position(|op| {
|
||||
matches!(
|
||||
op,
|
||||
OpCode::SetTargetInclination
|
||||
| OpCode::SetTargetResistanceLevel
|
||||
| OpCode::SetIndoorBikeSimulationParameters
|
||||
)
|
||||
})
|
||||
.expect("the sequence must zero something");
|
||||
let reset = ops
|
||||
.iter()
|
||||
.position(|op| *op == OpCode::Reset)
|
||||
.expect("the sequence must reset");
|
||||
assert!(first_zeroing < reset, "{ops:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safety_reset_in_simulation_mode_sends_zero_grade_and_zero_wind() {
|
||||
let caps = TrainerCapabilities {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
//! | [`indoor_bike_data`] | `0x2AD2` decoder | no |
|
||||
//! | [`control_point`] | `0x2AD9` encoders and response decoding | no |
|
||||
//! | [`capabilities`] | `0x2ACC`/`0x2AD5`/`0x2AD6`/`0x2AD8` decoding, and the safety gate | no |
|
||||
//! | [`zwift`] | Zwift's proprietary protocol (§2.3.1) | no |
|
||||
//! | [`scan`] | discovery | yes |
|
||||
//! | [`client`] | the connection actor | yes |
|
||||
//!
|
||||
@@ -47,13 +48,16 @@
|
||||
//! (REQUIREMENTS.md §3.2). Per-module attribution notes mark where.
|
||||
|
||||
pub mod capabilities;
|
||||
pub mod click;
|
||||
pub mod client;
|
||||
pub mod control_point;
|
||||
pub mod error;
|
||||
pub mod indoor_bike_data;
|
||||
pub mod scan;
|
||||
pub mod uuids;
|
||||
pub mod zwift;
|
||||
|
||||
pub use click::{ClickClient, ClickConfig, ClickEvent};
|
||||
pub use capabilities::{
|
||||
FitnessMachineFeature, InclinationRange, PowerRange, ResistanceLevelRange, TrainerCapabilities,
|
||||
UnsupportedTarget,
|
||||
@@ -68,3 +72,7 @@ pub use scan::{
|
||||
default_adapter, scan, scan_trainers, DiscoveredDevice, ScanKind, TrainerSelector,
|
||||
};
|
||||
pub use uuids::FITNESS_MACHINE_SERVICE;
|
||||
pub use zwift::{
|
||||
Button, ButtonBitmask, ClickButtons, DeviceKind as ZwiftDeviceKind,
|
||||
MessageType as ZwiftMessageType,
|
||||
};
|
||||
|
||||
+16
-10
@@ -8,20 +8,14 @@ use btleplug::platform::{Adapter, Manager, Peripheral, PeripheralId};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::FtmsError;
|
||||
use crate::uuids;
|
||||
use crate::{uuids, zwift};
|
||||
|
||||
/// Zwift's custom service UUID, used to recognise Click pods during a scan
|
||||
/// (FR-1.2). The Click *client* is Phase 3 and lives elsewhere; discovery only
|
||||
/// needs the UUID so `probe scan` can label them.
|
||||
pub const ZWIFT_SERVICE: Uuid = Uuid::from_fields(
|
||||
0x0000_0001,
|
||||
0x19CA,
|
||||
0x4651,
|
||||
&[0x86, 0xE5, 0xFA, 0x29, 0xDC, 0xDD, 0x09, 0xD1],
|
||||
);
|
||||
/// (FR-1.2). Re-exported from [`crate::zwift`], which owns the protocol.
|
||||
pub use crate::zwift::SERVICE as ZWIFT_SERVICE;
|
||||
|
||||
/// Zwift's Bluetooth SIG manufacturer ID (2378).
|
||||
pub const ZWIFT_MANUFACTURER_ID: u16 = 0x094A;
|
||||
pub use crate::zwift::MANUFACTURER_ID as ZWIFT_MANUFACTURER_ID;
|
||||
|
||||
/// A peripheral seen during a scan.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -48,11 +42,23 @@ impl DiscoveredDevice {
|
||||
}
|
||||
|
||||
/// True when the peripheral looks like a Zwift controller.
|
||||
///
|
||||
/// Note that the Van Rysel D100 also advertises Zwift's custom service, so
|
||||
/// this is "speaks the Zwift protocol", not "is a Click".
|
||||
pub fn is_zwift_device(&self) -> bool {
|
||||
self.services.contains(&ZWIFT_SERVICE)
|
||||
|| self.manufacturer_data.contains_key(&ZWIFT_MANUFACTURER_ID)
|
||||
}
|
||||
|
||||
/// Which Zwift device this is, from the type byte in its manufacturer data
|
||||
/// (§2.3.1). `None` when it advertises no Zwift manufacturer data at all —
|
||||
/// which is the case for a trainer that merely exposes the service.
|
||||
pub fn zwift_kind(&self) -> Option<zwift::DeviceKind> {
|
||||
self.manufacturer_data
|
||||
.get(&ZWIFT_MANUFACTURER_ID)
|
||||
.and_then(|d| zwift::DeviceKind::from_manufacturer_data(d))
|
||||
}
|
||||
|
||||
/// Best-effort human label.
|
||||
pub fn label(&self) -> String {
|
||||
match &self.name {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
||||
//! Metabolic energy expenditure — mechanical work in, kilocalories out.
|
||||
//!
|
||||
//! One model, used in two places: the live readout on the ride screen
|
||||
//! (`src-tauri/src/derive.rs`) and the `total_calories` written into the FIT
|
||||
//! activity (`bikecontrol-fit`). They must agree, so the arithmetic lives here
|
||||
//! rather than being written twice.
|
||||
//!
|
||||
//! **The model.** A rider burns metabolic energy in two ways during a ride:
|
||||
//!
|
||||
//! * *Work.* Mechanical work measured at the pedals, divided by the rider's
|
||||
//! efficiency at converting food energy into it. Cycling net efficiency sits
|
||||
//! around 20–25%; [`NET_EFFICIENCY`] takes the top of that range because the
|
||||
//! figure most riders compare against (Strava, Garmin) is effectively the
|
||||
//! 1 kJ ≈ 1 kcal convention, which corresponds to ~24%.
|
||||
//! * *Rest.* Being alive costs roughly one kilocalorie per kilogram per hour —
|
||||
//! the definition of 1 MET. Over a two-hour ride that is another ~150 kcal
|
||||
//! for an 80 kg rider, so it is worth counting rather than rounding away.
|
||||
//!
|
||||
//! Splitting the two is why this uses *net* efficiency (work above baseline)
|
||||
//! and not *gross* efficiency (which already has the resting cost folded in) —
|
||||
//! using gross efficiency and then adding rest back would count it twice.
|
||||
//!
|
||||
//! **What this is not.** It is an estimate, not a measurement. Real efficiency
|
||||
//! varies by rider, cadence and intensity, and no power meter can see the
|
||||
//! difference. Treat a figure from here as ±10%.
|
||||
|
||||
/// Joules in one dietary kilocalorie.
|
||||
pub const JOULES_PER_KCAL: f64 = 4184.0;
|
||||
|
||||
/// Fraction of the metabolic energy spent *above resting* that reaches the
|
||||
/// pedals as mechanical work.
|
||||
pub const NET_EFFICIENCY: f64 = 0.25;
|
||||
|
||||
/// Resting metabolic rate, kcal per kilogram of body mass per hour. This is
|
||||
/// 1 MET, the standard baseline.
|
||||
pub const RESTING_KCAL_PER_KG_HOUR: f64 = 1.0;
|
||||
|
||||
/// Kilocalories burned by `work_j` joules of pedalling spread over `active_s`
|
||||
/// seconds, by a rider of `rider_kg`.
|
||||
///
|
||||
/// `active_s` should be time the rider was actually riding — a paused ride
|
||||
/// still burns calories, but they are not this ride's to claim. Passing a
|
||||
/// `rider_kg` of zero (an unknown rider) drops the resting term and leaves the
|
||||
/// work term intact, which is the right degradation: an underestimate rather
|
||||
/// than a fabricated one.
|
||||
pub fn kcal(work_j: f64, rider_kg: f32, active_s: f64) -> f64 {
|
||||
let from_work = work_j.max(0.0) / JOULES_PER_KCAL / NET_EFFICIENCY;
|
||||
let from_rest =
|
||||
f64::from(rider_kg.max(0.0)) * RESTING_KCAL_PER_KG_HOUR * active_s.max(0.0) / 3600.0;
|
||||
from_work + from_rest
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The sanity check every cyclist knows: an hour at 250 W — 900 kJ of work
|
||||
/// — costs somewhere close to a thousand kilocalories. Anything far from
|
||||
/// that means the constants are wrong, whatever the arithmetic says.
|
||||
#[test]
|
||||
fn an_hour_at_250_w_is_about_a_thousand_kcal() {
|
||||
let work_j = 250.0 * 3600.0;
|
||||
let out = kcal(work_j, 75.0, 3600.0);
|
||||
assert!((900.0..=1000.0).contains(&out), "got {out} kcal");
|
||||
}
|
||||
|
||||
/// The work term alone must stay near the 1 kJ ≈ 1 kcal convention, so the
|
||||
/// number is recognisable next to the kJ readout beside it.
|
||||
#[test]
|
||||
fn work_alone_tracks_the_kilojoule_convention() {
|
||||
let ratio = kcal(1_000_000.0, 0.0, 0.0) / 1000.0;
|
||||
assert!((0.9..=1.1).contains(&ratio), "kcal/kJ ratio {ratio}");
|
||||
}
|
||||
|
||||
/// Resting metabolism accrues with time, not with work.
|
||||
#[test]
|
||||
fn resting_burn_accrues_without_any_work() {
|
||||
let out = kcal(0.0, 80.0, 3600.0);
|
||||
assert!((out - 80.0).abs() < 1e-9, "got {out} kcal");
|
||||
}
|
||||
|
||||
/// An unknown rider mass must not invent a resting burn.
|
||||
#[test]
|
||||
fn unknown_rider_mass_drops_the_resting_term() {
|
||||
assert_eq!(kcal(100_000.0, 0.0, 3600.0), kcal(100_000.0, 0.0, 0.0));
|
||||
}
|
||||
|
||||
/// Garbage in must not produce a negative calorie count.
|
||||
#[test]
|
||||
fn negative_inputs_clamp_rather_than_subtract() {
|
||||
assert_eq!(kcal(-500.0, -80.0, -60.0), 0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Virtual gears for a single-cog drivetrain (§5.4, FR-4.1).
|
||||
//!
|
||||
//! With a Zwift Cog there is one 14T sprocket and no way to shift, so the rider
|
||||
//! has exactly one gear. That is tolerable on the flat and useless everywhere
|
||||
//! else: on a climb they grind, and on a descent the trainer unloads, they spin
|
||||
//! out against nothing, and their effort stops contributing at precisely the
|
||||
//! moment they can see the speed rising.
|
||||
//!
|
||||
//! FTMS has no virtual-shifting op code — Zwift's own implementation is
|
||||
//! proprietary — so gearing has to be synthesised from what the trainer does
|
||||
//! expose. The D100 accepts `SetIndoorBikeSimulationParameters`, so a gear is
|
||||
//! expressed as an **offset to the gradient the trainer is asked to simulate**:
|
||||
//! a harder gear asks for a steeper hill and therefore more load.
|
||||
//!
|
||||
//! Two gradients therefore exist and must not be confused:
|
||||
//!
|
||||
//! * the **route** gradient, which the physics model uses, so speed still
|
||||
//! reflects the terrain;
|
||||
//! * the **commanded** gradient — route plus gear offset — which only decides
|
||||
//! how hard the pedals feel.
|
||||
//!
|
||||
//! Shifting consequently changes effort, not speed, exactly as on a real bike.
|
||||
//! Speed changes only as a *result*: a harder gear at the same cadence produces
|
||||
//! more watts, and more watts produce more speed through the physics.
|
||||
//!
|
||||
//! The percent-per-gear mapping is a pragmatic stand-in for a proper torque
|
||||
//! model and **wants calibrating against the real resistance curve** (TASK-3 in
|
||||
//! REQUIREMENTS.md, still outstanding). The defaults are a starting point, not
|
||||
//! a measured result.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A ladder of load offsets, easiest first.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct VirtualCassette {
|
||||
/// Gradient offset per gear, in percent. Ascending.
|
||||
offsets: Vec<f32>,
|
||||
}
|
||||
|
||||
impl VirtualCassette {
|
||||
/// Evenly spaced gears between two offsets.
|
||||
///
|
||||
/// `easiest` is normally negative — it *removes* load, so the rider can
|
||||
/// still turn the pedals on a steep climb. `hardest` is positive, which is
|
||||
/// what makes a descent rideable rather than a spin-out.
|
||||
pub fn linear(gears: usize, easiest_pct: f32, hardest_pct: f32) -> Self {
|
||||
let gears = gears.max(1);
|
||||
if gears == 1 {
|
||||
return Self { offsets: vec![0.0] };
|
||||
}
|
||||
let step = (hardest_pct - easiest_pct) / (gears - 1) as f32;
|
||||
Self {
|
||||
offsets: (0..gears).map(|i| easiest_pct + step * i as f32).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.offsets.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.offsets.is_empty()
|
||||
}
|
||||
|
||||
pub fn offset_pct(&self, gear: usize) -> f32 {
|
||||
self.offsets
|
||||
.get(gear.min(self.offsets.len().saturating_sub(1)))
|
||||
.copied()
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl VirtualCassette {
|
||||
/// A ladder with an exact **zero** rung at `neutral`, stepping by `step`
|
||||
/// either side.
|
||||
///
|
||||
/// The zero matters: it is the gear in which the trainer is asked for
|
||||
/// precisely the route's gradient and nothing else, so a rider who never
|
||||
/// shifts gets exactly the behaviour they had before gears existed.
|
||||
pub fn centred(gears: usize, neutral: usize, step: f32) -> Self {
|
||||
let gears = gears.max(1);
|
||||
let neutral = neutral.min(gears - 1);
|
||||
Self {
|
||||
offsets: (0..gears)
|
||||
.map(|i| (i as f32 - neutral as f32) * step)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Index of the gear whose offset is nearest neutral.
|
||||
pub fn neutral_gear(&self) -> usize {
|
||||
self.offsets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.min_by(|a, b| a.1.abs().total_cmp(&b.1.abs()))
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for VirtualCassette {
|
||||
/// Twelve gears in 0.75% steps, neutral at gear 5, spanning −3% to +5.25%.
|
||||
/// The asymmetry is deliberate: shedding load on a climb matters less than
|
||||
/// being able to *find* load on a descent, which is the failure this module
|
||||
/// exists to fix.
|
||||
fn default() -> Self {
|
||||
Self::centred(12, 4, 0.75)
|
||||
}
|
||||
}
|
||||
|
||||
/// The rider's current gear selection.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Gearing {
|
||||
cassette: VirtualCassette,
|
||||
gear: usize,
|
||||
}
|
||||
|
||||
impl Default for Gearing {
|
||||
fn default() -> Self {
|
||||
let cassette = VirtualCassette::default();
|
||||
// Start in the neutral gear so an un-shifted ride behaves exactly as it
|
||||
// did before gears existed — no silent change to the commanded gradient.
|
||||
let gear = cassette.neutral_gear();
|
||||
Self { cassette, gear }
|
||||
}
|
||||
}
|
||||
|
||||
impl Gearing {
|
||||
pub fn new(cassette: VirtualCassette) -> Self {
|
||||
let gear = cassette.neutral_gear();
|
||||
Self { cassette, gear }
|
||||
}
|
||||
|
||||
/// One-based, because riders count gears from one.
|
||||
pub fn gear(&self) -> usize {
|
||||
self.gear + 1
|
||||
}
|
||||
|
||||
pub fn gear_count(&self) -> usize {
|
||||
self.cassette.len()
|
||||
}
|
||||
|
||||
/// Load offset in simulated-gradient percent for the selected gear.
|
||||
pub fn offset_pct(&self) -> f32 {
|
||||
self.cassette.offset_pct(self.gear)
|
||||
}
|
||||
|
||||
/// Shift to a harder gear. Clamps at the top — never wraps (FR-4.1.3),
|
||||
/// because wrapping from hardest to easiest mid-climb would be violent.
|
||||
pub fn shift_up(&mut self) -> bool {
|
||||
if self.gear + 1 < self.cassette.len() {
|
||||
self.gear += 1;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Shift to an easier gear. Clamps at the bottom.
|
||||
pub fn shift_down(&mut self) -> bool {
|
||||
if self.gear > 0 {
|
||||
self.gear -= 1;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Select a gear directly, one-based. Out-of-range values clamp.
|
||||
pub fn set_gear(&mut self, one_based: usize) {
|
||||
let idx = one_based.saturating_sub(1);
|
||||
self.gear = idx.min(self.cassette.len().saturating_sub(1));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_default_cassette_spans_easier_and_harder_than_neutral() {
|
||||
let g = Gearing::default();
|
||||
assert_eq!(g.gear_count(), 12);
|
||||
let c = &g.cassette;
|
||||
assert!(c.offset_pct(0) < 0.0, "bottom gear must shed load");
|
||||
assert!(c.offset_pct(11) > 0.0, "top gear must add load");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unshifted_ride_commands_exactly_the_route_gradient() {
|
||||
// Gears must not silently alter the ride for someone who never shifts.
|
||||
let g = Gearing::default();
|
||||
assert_eq!(g.offset_pct(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shifting_is_monotonic_and_clamps_at_both_ends() {
|
||||
let mut g = Gearing::new(VirtualCassette::linear(5, -2.0, 4.0));
|
||||
while g.shift_down() {}
|
||||
assert_eq!(g.gear(), 1);
|
||||
assert!(!g.shift_down(), "must not wrap past the bottom");
|
||||
let bottom = g.offset_pct();
|
||||
|
||||
let mut previous = bottom;
|
||||
while g.shift_up() {
|
||||
let now = g.offset_pct();
|
||||
assert!(now > previous, "each shift up must add load");
|
||||
previous = now;
|
||||
}
|
||||
assert_eq!(g.gear(), 5);
|
||||
assert!(!g.shift_up(), "must not wrap past the top");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hard_gear_finds_load_on_a_descent() {
|
||||
// The failure this module exists to fix: on a -6% descent the trainer
|
||||
// unloads and the rider spins out. Selecting a hard gear must bring the
|
||||
// commanded gradient back to something they can push against.
|
||||
let mut g = Gearing::new(VirtualCassette::default());
|
||||
while g.shift_up() {}
|
||||
let commanded = -6.0 + g.offset_pct();
|
||||
assert!(
|
||||
commanded > -1.0,
|
||||
"top gear should recover load on a descent, got {commanded}%"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_speed_cassette_is_neutral() {
|
||||
let g = Gearing::new(VirtualCassette::linear(1, -3.0, 6.0));
|
||||
assert_eq!(g.gear_count(), 1);
|
||||
assert_eq!(g.offset_pct(), 0.0);
|
||||
}
|
||||
}
|
||||
@@ -191,6 +191,14 @@ pub fn haversine_m(a: TrackPoint, b: TrackPoint) -> f64 {
|
||||
/// 2. Resample elevation onto an even `resample_m` grid. Uneven GPS spacing
|
||||
/// otherwise weights a stationary cluster of fixes as heavily as a fast
|
||||
/// descent.
|
||||
/// 2b. Reject *outliers* — single fixes metres away from their neighbours —
|
||||
/// before any averaging. A moving average does not remove an outlier, it
|
||||
/// smears it across the whole window, and the differentiation in step 4
|
||||
/// then reads that smear as a sustained gradient. The commonest instance is
|
||||
/// the first fix of a recorded activity, taken before the receiver has
|
||||
/// settled: on a real 22.6 km file the opening fix sat 9 m above the road
|
||||
/// and produced a phantom −9.5% descent that the trainer was then asked to
|
||||
/// reproduce.
|
||||
/// 3. Smooth elevation with two cascaded centred moving averages `window_m`
|
||||
/// wide, over a reflected extension so the window never truncates at the
|
||||
/// ends. Consumer GPS elevation carries metres of noise; differentiating it
|
||||
@@ -263,6 +271,9 @@ pub fn to_terrain(
|
||||
grid_ele.push(y0 + (y1 - y0) * f);
|
||||
}
|
||||
|
||||
// 2b. Reject outliers before averaging anything. See `despike`.
|
||||
despike(&mut grid_ele, spacing);
|
||||
|
||||
// 3. Smooth, over a reflected extension of the series so that the window
|
||||
// stays full width at the ends. Truncating the window instead leaves
|
||||
// the first and last samples barely smoothed, and since step 4 reads
|
||||
@@ -348,6 +359,100 @@ fn gradient_bounds(cfg: &SmoothingConfig) -> (f32, f32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Width of the outlier-rejection window, metres. Wide enough that the spread
|
||||
/// estimate over it is stable — a window of a handful of samples produces a
|
||||
/// noisy σ, and a noisy σ makes the filter fire on ordinary noise — and narrow
|
||||
/// enough that a road's curvature across it stays below [`DESPIKE_FLOOR_M`].
|
||||
const DESPIKE_WINDOW_M: f64 = 150.0;
|
||||
/// How many robust standard deviations from the local trend counts as an
|
||||
/// outlier.
|
||||
const DESPIKE_K: f32 = 4.0;
|
||||
/// A sample is never rejected for deviating less than this, in metres.
|
||||
///
|
||||
/// Two jobs. It stops a genuinely smooth stretch — where the estimated spread
|
||||
/// is near zero — from having every millimetre of wobble called an outlier.
|
||||
/// And it keeps the filter clear of ordinary consumer-GPS elevation noise,
|
||||
/// which runs to ±1.5–3 m: that is the smoothing window's problem to solve,
|
||||
/// not this one's. Set below the errors that actually matter, which are the
|
||||
/// 5–10 m variety.
|
||||
const DESPIKE_FLOOR_M: f32 = 4.0;
|
||||
|
||||
/// Lower-median of a slice, sorted in place. NaN-safe via `total_cmp`.
|
||||
fn median(values: &mut [f32]) -> f32 {
|
||||
if values.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
values.sort_by(|a, b| a.total_cmp(b));
|
||||
values[values.len() / 2]
|
||||
}
|
||||
|
||||
/// Replace elevation samples that are not on the road with the road.
|
||||
///
|
||||
/// This is a Hampel filter with one addition that matters here: the local
|
||||
/// trend is removed before the test. A plain median filter is already immune
|
||||
/// to a *linear* trend in the middle of a series, because the median of a
|
||||
/// symmetric window through a ramp is its centre value — but not at the ends,
|
||||
/// where the window can only look one way. Since the single most common
|
||||
/// outlier in a real GPX is the *first* fix of the recording, taken before the
|
||||
/// receiver has settled, the ends are exactly where this has to work.
|
||||
///
|
||||
/// So each window's slope is estimated robustly (the median of its consecutive
|
||||
/// differences, which one bad sample cannot move), every sample in the window
|
||||
/// is projected along that slope to the position under test, and the median of
|
||||
/// those projections is what the sample is compared against. A sample further
|
||||
/// than `max(K·σ, floor)` from it is not terrain and is replaced.
|
||||
///
|
||||
/// Why this cannot be left to the smoothing that follows: averaging does not
|
||||
/// remove an outlier, it spreads it over the whole window, and differentiating
|
||||
/// that smear yields a gradient that is sustained rather than transient. A 9 m
|
||||
/// first-fix error produced a −9.5% opening descent on a road that was flat,
|
||||
/// and that gradient was commanded to the trainer.
|
||||
fn despike(grid: &mut [f32], spacing: f64) {
|
||||
let n = grid.len();
|
||||
let radius = ((DESPIKE_WINDOW_M / spacing.max(f64::MIN_POSITIVE)) * 0.5).round();
|
||||
let radius = (radius.max(3.0) as usize).min(n);
|
||||
let width = 2 * radius + 1;
|
||||
if n < width {
|
||||
// Too short to tell an outlier from the shape of the road.
|
||||
return;
|
||||
}
|
||||
|
||||
let src = grid.to_vec();
|
||||
let mut diffs = Vec::with_capacity(width - 1);
|
||||
let mut projected = Vec::with_capacity(width);
|
||||
let mut deviations = Vec::with_capacity(width);
|
||||
|
||||
for (i, out) in grid.iter_mut().enumerate() {
|
||||
// A full-width window of the nearest samples: centred in the interior,
|
||||
// slid inward at the ends so the estimate never runs short of data.
|
||||
let start = i.saturating_sub(radius).min(n - width);
|
||||
let window = &src[start..start + width];
|
||||
|
||||
diffs.clear();
|
||||
diffs.extend(window.windows(2).map(|w| w[1] - w[0]));
|
||||
let slope = median(&mut diffs);
|
||||
|
||||
projected.clear();
|
||||
projected.extend(
|
||||
window
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(k, v)| v + slope * (i as f32 - (start + k) as f32)),
|
||||
);
|
||||
let predicted = median(&mut projected);
|
||||
|
||||
deviations.clear();
|
||||
deviations.extend(projected.iter().map(|v| (v - predicted).abs()));
|
||||
// 1.4826·MAD estimates σ for normally distributed noise.
|
||||
let sigma = 1.4826 * median(&mut deviations);
|
||||
let threshold = (DESPIKE_K * sigma).max(DESPIKE_FLOOR_M);
|
||||
|
||||
if (src[i] - predicted).abs() > threshold {
|
||||
*out = predicted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Centred moving average over `2·half + 1` samples, with the window truncated
|
||||
/// symmetrically at the ends so the series is not phase-shifted. Prefix sums
|
||||
/// in f64 keep it O(n) without losing precision on long tracks.
|
||||
@@ -911,6 +1016,121 @@ mod tests {
|
||||
assert!(terrain.iter().all(|p| p.gradient_pct.is_finite()));
|
||||
}
|
||||
|
||||
// ---- outlier rejection ------------------------------------------------
|
||||
|
||||
/// The shape of the shipped fixture, asserted end to end: flat opening,
|
||||
/// then a real climb, then a real descent.
|
||||
///
|
||||
/// This is the test that catches a reversed distance axis, an off-by-one
|
||||
/// in the terrain lookup, or a sign error in the differentiation — any of
|
||||
/// which would put the climb where the descent is, or invert both.
|
||||
#[test]
|
||||
fn the_sample_climb_reads_flat_then_up_then_down_in_that_order() {
|
||||
let terrain = to_terrain(&parse(SAMPLE_CLIMB).unwrap(), &SmoothingConfig::default())
|
||||
.expect("fixture imports");
|
||||
|
||||
let opening = mean_gradient(&terrain, 0.0, 400.0);
|
||||
let climb = mean_gradient(&terrain, 600.0, 2_200.0);
|
||||
let descent = mean_gradient(&terrain, 2_500.0, 2_900.0);
|
||||
|
||||
assert!(opening.abs() < 1.0, "opening should be flat, got {opening}%");
|
||||
assert!(
|
||||
(4.0..9.0).contains(&climb),
|
||||
"climb should be 4–9%, got {climb}%"
|
||||
);
|
||||
assert!(
|
||||
(-6.0..-3.0).contains(&descent),
|
||||
"descent should be about -4.5%, got {descent}%"
|
||||
);
|
||||
|
||||
// And the same read through the profile the app actually rides, so a
|
||||
// fault in the block indexing cannot hide behind a correct terrain
|
||||
// series.
|
||||
let profile = import(SAMPLE_CLIMB, "sample", &SmoothingConfig::default()).unwrap();
|
||||
let at = |d: f64| {
|
||||
profile
|
||||
.sample_channel(crate::profile::Position {
|
||||
elapsed_s: 0.0,
|
||||
distance_m: d,
|
||||
})
|
||||
.expect("in range")
|
||||
.1
|
||||
};
|
||||
assert!(at(200.0).abs() < 1.5, "flat at 200 m: {}", at(200.0));
|
||||
assert!(at(1_000.0) > 3.0, "climbing at 1 km: {}", at(1_000.0));
|
||||
assert!(at(2_700.0) < -2.0, "descending at 2.7 km: {}", at(2_700.0));
|
||||
}
|
||||
|
||||
/// The first fix of a recorded activity is routinely metres out, because
|
||||
/// the receiver has not settled. Averaging spreads that error over the
|
||||
/// whole smoothing window and the differentiation then reads it as a
|
||||
/// sustained gradient — on a real 22.6 km file, a 9 m first fix produced a
|
||||
/// −9.5% descent at the start of a flat road, which was commanded to the
|
||||
/// trainer.
|
||||
#[test]
|
||||
fn a_bad_first_fix_does_not_become_an_opening_descent() {
|
||||
let mut elevations = vec![100.0f32; 200];
|
||||
let clean = to_terrain(&synthetic_track(&elevations, 10.0), &SmoothingConfig::default())
|
||||
.unwrap();
|
||||
assert!(clean[0].gradient_pct.abs() < 0.2, "control: {clean:?}");
|
||||
|
||||
// One bad sample, at the worst possible place.
|
||||
elevations[0] = 109.0;
|
||||
let spiked =
|
||||
to_terrain(&synthetic_track(&elevations, 10.0), &SmoothingConfig::default()).unwrap();
|
||||
|
||||
assert!(
|
||||
spiked[0].gradient_pct.abs() < 1.0,
|
||||
"a 9 m first-fix error became a {}% gradient",
|
||||
spiked[0].gradient_pct
|
||||
);
|
||||
// And it must not have shifted the road it sits on either.
|
||||
assert!(
|
||||
(spiked[0].elevation_m - 100.0).abs() < 1.0,
|
||||
"elevation dragged to {} m",
|
||||
spiked[0].elevation_m
|
||||
);
|
||||
}
|
||||
|
||||
/// The same treatment must leave a genuine gradient alone — including one
|
||||
/// that starts at the very first sample, where the rejection window can
|
||||
/// only look forwards.
|
||||
#[test]
|
||||
fn a_real_climb_is_not_mistaken_for_an_outlier() {
|
||||
// A constant 8% from the first metre.
|
||||
let elevations: Vec<f32> = (0..300).map(|i| 100.0 + i as f32 * 0.8).collect();
|
||||
let terrain =
|
||||
to_terrain(&synthetic_track(&elevations, 10.0), &SmoothingConfig::default()).unwrap();
|
||||
|
||||
for p in terrain.iter().take(50) {
|
||||
assert!(
|
||||
(7.0..9.0).contains(&p.gradient_pct),
|
||||
"real 8% climb reported as {}% at {} m",
|
||||
p.gradient_pct,
|
||||
p.distance_m
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A spike in the middle of a ride — a dropped fix, a tunnel — is rejected
|
||||
/// on the same terms, and does not leave a gradient step behind.
|
||||
#[test]
|
||||
fn a_mid_ride_elevation_spike_is_rejected() {
|
||||
let mut elevations: Vec<f32> = (0..400).map(|i| 100.0 + i as f32 * 0.2).collect();
|
||||
let baseline =
|
||||
to_terrain(&synthetic_track(&elevations, 10.0), &SmoothingConfig::default()).unwrap();
|
||||
elevations[200] += 12.0;
|
||||
let spiked =
|
||||
to_terrain(&synthetic_track(&elevations, 10.0), &SmoothingConfig::default()).unwrap();
|
||||
|
||||
let worst = spiked
|
||||
.iter()
|
||||
.zip(&baseline)
|
||||
.map(|(a, b)| (a.gradient_pct - b.gradient_pct).abs())
|
||||
.fold(0.0f32, f32::max);
|
||||
assert!(worst < 1.0, "a 12 m spike moved the gradient by {worst}%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_propagates_parse_errors() {
|
||||
assert!(matches!(
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
//! is unit-testable with synthetic telemetry, and it must stay that way — BLE
|
||||
//! lives in `bikecontrol-ble`, file writing in `bikecontrol-fit`.
|
||||
|
||||
pub mod energy;
|
||||
pub mod gearing;
|
||||
pub mod gpx;
|
||||
pub mod physics;
|
||||
pub mod profile;
|
||||
@@ -11,6 +13,7 @@ pub mod session;
|
||||
pub mod types;
|
||||
|
||||
pub use profile::{Block, Channel, Extent, Profile, Segment, Waveform};
|
||||
pub use gearing::{Gearing, VirtualCassette};
|
||||
pub use session::{RideSession, SessionEvent};
|
||||
pub use types::{
|
||||
ConnectionState, ControlMode, ControlTarget, RideSnapshot, RiderConfig, SafetyLimits, Telemetry,
|
||||
|
||||
+125
-1
@@ -28,6 +28,17 @@ pub const MIN_SPEED_MPS: f32 = 0.5;
|
||||
/// absurd configuration (CdA of zero, a 90% descent) still cannot run away.
|
||||
pub const MAX_SPEED_MPS: f32 = 40.0;
|
||||
|
||||
/// Ceiling on *measured* power fed to the model. FTMS Instantaneous Power is a
|
||||
/// sint16, so a glitched packet can legitimately decode to 32767 W — which the
|
||||
/// force balance faithfully turns into a 144 km/h ride. No human produces more
|
||||
/// than ~2500 W even for a single track-sprint pedal stroke, so anything above
|
||||
/// this is a bad reading, not a rider.
|
||||
///
|
||||
/// This is deliberately *not* [`crate::types::SafetyLimits::max_power_w`]: that
|
||||
/// one bounds the ERG target we *command*, this one bounds the power we
|
||||
/// *believe*.
|
||||
pub const MAX_MEASURED_POWER_W: f32 = 2500.0;
|
||||
|
||||
/// Longest tick the integrator will honour. A caller that stalls for a minute
|
||||
/// must not be allowed to teleport the rider down a mountain.
|
||||
const MAX_DT_S: f32 = 10.0;
|
||||
@@ -105,6 +116,32 @@ impl PhysicsState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the modelled speed toward one the trainer actually measured.
|
||||
///
|
||||
/// The model knows what a bike *would* do for a given power and gradient;
|
||||
/// the trainer knows how fast its flywheel is really turning. Neither alone
|
||||
/// is right on a single-cog drivetrain: pure physics lets the rider "coast"
|
||||
/// downhill at 39 km/h while spinning out against no resistance, and pure
|
||||
/// trainer speed would cap descents at whatever cadence the one gear allows.
|
||||
///
|
||||
/// `weight` is the fraction of the gap closed **per second**, so the result
|
||||
/// does not depend on tick rate — a 4 Hz and a 10 Hz loop converge the same.
|
||||
pub fn correct_toward(&mut self, measured_mps: f32, weight: f32, dt: f32) {
|
||||
if !measured_mps.is_finite() || measured_mps < 0.0 || !dt.is_finite() || dt <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let w = weight.clamp(0.0, 1.0);
|
||||
if w == 0.0 {
|
||||
return;
|
||||
}
|
||||
// Fraction of the gap to close this tick, from the per-second rate.
|
||||
let alpha = 1.0 - (1.0 - w).powf(dt.min(MAX_DT_S));
|
||||
let corrected = self.speed_mps + (measured_mps - self.speed_mps) * alpha;
|
||||
if corrected.is_finite() {
|
||||
self.speed_mps = corrected.clamp(0.0, MAX_SPEED_MPS);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn speed_kph(&self) -> f32 {
|
||||
self.speed_mps * 3.6
|
||||
}
|
||||
@@ -129,7 +166,10 @@ struct Forces {
|
||||
impl Forces {
|
||||
fn new(power_w: f32, gradient_pct: f32, cfg: &RiderConfig) -> Self {
|
||||
// Braking is not modelled, so negative power is treated as coasting.
|
||||
let power = sanitise(power_w, 0.0).max(0.0);
|
||||
// The upper clamp is what keeps a glitched FTMS sample from driving the
|
||||
// ride at 144 km/h; the integrator is stable and drift-free on its own,
|
||||
// but it cannot tell an implausible input from a real one.
|
||||
let power = sanitise(power_w, 0.0).clamp(0.0, MAX_MEASURED_POWER_W);
|
||||
let gradient =
|
||||
sanitise(gradient_pct, 0.0).clamp(-MAX_ABS_GRADIENT_PCT, MAX_ABS_GRADIENT_PCT);
|
||||
let theta = (gradient / 100.0).atan();
|
||||
@@ -477,6 +517,90 @@ mod tests {
|
||||
assert_eq!(s, before);
|
||||
}
|
||||
|
||||
/// The integrator must not creep. Forward Euler's discrete fixed point is
|
||||
/// exactly the root of `a(v)`, i.e. the continuous equilibrium, so a steady
|
||||
/// effort held for hours must not accumulate its way to a higher speed. A
|
||||
/// higher-order scheme would not improve this — it shares the same fixed
|
||||
/// point — so this test, not the integration order, is the guarantee.
|
||||
#[test]
|
||||
fn a_long_steady_ride_does_not_drift_upwards() {
|
||||
let c = cfg();
|
||||
let target = equilibrium_speed_mps(250.0, 0.0, &c);
|
||||
let mut s = PhysicsState::default();
|
||||
|
||||
// Settle first, then hold for six hours of ride time.
|
||||
for _ in 0..2_400 {
|
||||
s.step(250.0, 0.0, &c, 0.25);
|
||||
}
|
||||
let after_settling = s.speed_mps;
|
||||
for _ in 0..86_400 {
|
||||
s.step(250.0, 0.0, &c, 0.25);
|
||||
}
|
||||
|
||||
assert!(
|
||||
(s.speed_mps - after_settling).abs() < 1.0e-3,
|
||||
"speed crept from {after_settling} to {} over six hours",
|
||||
s.speed_mps
|
||||
);
|
||||
assert!(
|
||||
s.speed_mps <= target + 1.0e-3,
|
||||
"settled {} above equilibrium {target}",
|
||||
s.speed_mps
|
||||
);
|
||||
}
|
||||
|
||||
/// Equilibrium is a fixed point *exactly*, not approximately: stepping from
|
||||
/// it must not move. This is the property that makes drift impossible.
|
||||
#[test]
|
||||
fn stepping_from_equilibrium_does_not_move() {
|
||||
let c = cfg();
|
||||
for (power, gradient) in [(200.0, 0.0), (300.0, 5.0), (150.0, -2.0)] {
|
||||
let v = equilibrium_speed_mps(power, gradient, &c);
|
||||
let mut s = PhysicsState {
|
||||
speed_mps: v,
|
||||
..Default::default()
|
||||
};
|
||||
s.step(power, gradient, &c, 1.0);
|
||||
assert!(
|
||||
(s.speed_mps - v).abs() < 1.0e-4,
|
||||
"P={power} g={gradient}: {v} -> {}",
|
||||
s.speed_mps
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A glitched FTMS sample is a sint16, so it can decode to 32767 W. That
|
||||
/// must not become a 144 km/h ride.
|
||||
#[test]
|
||||
fn implausible_power_cannot_drive_an_implausible_speed() {
|
||||
let c = cfg();
|
||||
let sane = settle(MAX_MEASURED_POWER_W, 0.0, 300.0).speed_mps;
|
||||
|
||||
for absurd in [3_000.0, 10_000.0, 32_767.0] {
|
||||
let s = settle(absurd, 0.0, 300.0);
|
||||
assert!(
|
||||
(s.speed_mps - sane).abs() < 1.0e-3,
|
||||
"{absurd} W settled at {} m/s, above the {sane} m/s ceiling",
|
||||
s.speed_mps
|
||||
);
|
||||
assert!(
|
||||
s.speed_mps < MAX_SPEED_MPS,
|
||||
"{absurd} W pinned the speed at the absolute clamp"
|
||||
);
|
||||
}
|
||||
|
||||
// Real efforts, including a hard sprint, must be untouched by the clamp.
|
||||
for real in [250.0, 600.0, 1_200.0, 2_000.0] {
|
||||
let s = settle(real, 0.0, 300.0);
|
||||
let expected = equilibrium_speed_mps(real, 0.0, &c);
|
||||
assert!(
|
||||
(s.speed_mps - expected).abs() < 0.05,
|
||||
"{real} W was clamped: {} vs {expected}",
|
||||
s.speed_mps
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_kph_conversion() {
|
||||
let s = PhysicsState {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! This is the piece the Tauri layer drives. It takes telemetry in, produces
|
||||
//! snapshots and control targets out, and knows nothing about BLE or the UI.
|
||||
|
||||
use crate::gearing::Gearing;
|
||||
use crate::physics::PhysicsState;
|
||||
use crate::profile::{Position, Profile};
|
||||
use crate::types::{
|
||||
@@ -51,6 +52,9 @@ pub struct RideSession {
|
||||
manual_resistance: i16,
|
||||
/// Wattage held in [`ControlMode::Erg`] (FR-4.6).
|
||||
erg_watts: u16,
|
||||
/// Virtual gears (FR-4.1): changes how hard the pedals feel, not how
|
||||
/// fast the rider travels for a given power.
|
||||
pub gearing: Gearing,
|
||||
elapsed_ms: u64,
|
||||
last_target: Option<ControlTarget>,
|
||||
}
|
||||
@@ -67,6 +71,7 @@ impl RideSession {
|
||||
gradient_offset_pct: 0.0,
|
||||
manual_resistance: 0,
|
||||
erg_watts: 150,
|
||||
gearing: Gearing::default(),
|
||||
elapsed_ms: 0,
|
||||
last_target: None,
|
||||
}
|
||||
@@ -177,12 +182,31 @@ impl RideSession {
|
||||
let power_w = f32::from(telemetry.power_w.unwrap_or(0)).max(0.0);
|
||||
self.physics
|
||||
.step(power_w, self.simulated_gradient_pct(), &self.config, dt);
|
||||
// Pull the model back toward what the flywheel is really doing.
|
||||
// Pure physics lets a spun-out rider "coast" downhill at 39 km/h.
|
||||
if let Some(kph) = telemetry.speed_kph {
|
||||
self.physics
|
||||
.correct_toward(kph / 3.6, self.config.trainer_speed_weight, dt);
|
||||
}
|
||||
}
|
||||
|
||||
// Only a running ride commands the trainer. When paused or finished the
|
||||
// last target simply stands (SAF-1) rather than being re-sent or reset.
|
||||
if running {
|
||||
if let Some(target) = desired {
|
||||
// Keep load under the pedals on descents so the rider's effort
|
||||
// still counts; the physics above already used the true route
|
||||
// gradient, so the descent stays as fast as the terrain says.
|
||||
let target = match target {
|
||||
// Gear offset applies to what the TRAINER is asked for, not
|
||||
// to what the physics simulated: shifting changes effort,
|
||||
// not the speed the terrain implies.
|
||||
ControlTarget::Gradient { percent } => ControlTarget::Gradient {
|
||||
percent: (percent + self.gearing.offset_pct())
|
||||
.max(self.config.descent_load_floor_pct),
|
||||
},
|
||||
other => other,
|
||||
};
|
||||
let clamped = self.limits.clamp(target);
|
||||
if changed_meaningfully(self.last_target, clamped) {
|
||||
self.last_target = Some(clamped);
|
||||
@@ -205,7 +229,14 @@ impl RideSession {
|
||||
RideSnapshot {
|
||||
elapsed_ms: self.elapsed_ms,
|
||||
telemetry,
|
||||
virtual_speed_kph: self.physics.speed_kph(),
|
||||
// A paused or finished ride is a rider who is not moving. Holding
|
||||
// the last *target* when input is lost is correct (SAF-1); holding
|
||||
// the last *speed* is not — it tells a stationary rider they are
|
||||
// doing 39 km/h. Distance is retained, because it happened.
|
||||
virtual_speed_kph: match self.status {
|
||||
RideStatus::Running => self.physics.speed_kph(),
|
||||
_ => 0.0,
|
||||
},
|
||||
virtual_distance_m: self.physics.distance_m,
|
||||
gradient_pct: self.simulated_gradient_pct(),
|
||||
elevation_gain_m: self.physics.elevation_gain_m,
|
||||
@@ -532,6 +563,9 @@ mod tests {
|
||||
let target = commands(&s.tick(powered(0), 1.0))[0];
|
||||
assert_eq!(gradient_of(target), s.limits.max_gradient_pct);
|
||||
|
||||
// The descent load floor normally bites first, so disable it here to
|
||||
// prove the *safety* clamp still holds on its own.
|
||||
s.config.descent_load_floor_pct = f32::NEG_INFINITY;
|
||||
s.reset_gradient_offset();
|
||||
s.nudge_gradient(-90.0);
|
||||
// Two ticks: the first re-emits after the reset.
|
||||
@@ -543,6 +577,26 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descents_keep_load_under_the_pedals() {
|
||||
// A steep descent commands almost no resistance, so on a single-cog
|
||||
// drivetrain the rider spins out and their effort stops counting. The
|
||||
// floor keeps something to push against.
|
||||
let mut s = session();
|
||||
s.start();
|
||||
s.config.descent_load_floor_pct = -1.0;
|
||||
s.nudge_gradient(-8.0);
|
||||
let commanded = gradient_of(commands(&s.tick(powered(0), 1.0))[0]);
|
||||
assert_eq!(commanded, -1.0, "descent should be floored for load");
|
||||
|
||||
// But the *simulated* gradient stays true to the terrain, so the rider
|
||||
// still descends at the speed the route implies.
|
||||
assert!(
|
||||
s.snapshot(powered(0)).gradient_pct < -7.0,
|
||||
"physics must still see the real descent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_absurd_profile_cannot_command_an_unsafe_target() {
|
||||
// SAF-6: parameter errors must be caught by SAF-3, not by the profile.
|
||||
@@ -861,4 +915,29 @@ mod tests {
|
||||
assert!((snap.elevation_gain_m - expected).abs() < expected * 0.02);
|
||||
assert!(snap.elevation_gain_m > 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paused_ride_reports_zero_speed_not_the_last_reading() {
|
||||
let mut s = session();
|
||||
s.start();
|
||||
// Build up real speed under power.
|
||||
for _ in 0..40 {
|
||||
s.tick(powered(250), 0.25);
|
||||
}
|
||||
let moving = s.snapshot(powered(250)).virtual_speed_kph;
|
||||
assert!(moving > 5.0, "expected to be moving, got {moving} kph");
|
||||
|
||||
// Pause. The rider is stationary — reporting the last speed would tell
|
||||
// them they are still doing 30-odd kph while stood still.
|
||||
s.pause();
|
||||
let paused = s.snapshot(powered(0));
|
||||
assert_eq!(paused.virtual_speed_kph, 0.0);
|
||||
// Distance is retained: it happened.
|
||||
assert!(paused.virtual_distance_m > 0.0);
|
||||
|
||||
// Resuming picks the speed back up rather than restarting from rest.
|
||||
s.start();
|
||||
assert!(s.snapshot(powered(250)).virtual_speed_kph > 5.0);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -109,6 +109,36 @@ pub struct RiderConfig {
|
||||
/// Air density, kg/m³.
|
||||
pub air_density: f32,
|
||||
pub wheel_circumference_m: f32,
|
||||
/// How strongly the trainer's own reported speed pulls the modelled speed
|
||||
/// back toward it, as a fraction of the gap closed per second.
|
||||
///
|
||||
/// `0.0` is pure physics: correct for a real bike, but on a single-cog
|
||||
/// drivetrain the rider spins out against no resistance on a descent while
|
||||
/// the model happily reports 39 km/h. `1.0` would track the flywheel
|
||||
/// exactly, capping descents at whatever the one gear allows. The default
|
||||
/// keeps physics in charge while refusing to drift far from what the
|
||||
/// hardware measures.
|
||||
#[serde(default = "default_trainer_speed_weight")]
|
||||
pub trainer_speed_weight: f32,
|
||||
/// The steepest descent the trainer is ever *asked* to simulate.
|
||||
///
|
||||
/// On a real descent a trainer unloads almost completely, and on a
|
||||
/// single-cog drivetrain the rider then spins out against nothing and can
|
||||
/// produce no watts at all — so their effort stops mattering exactly when
|
||||
/// they can see the speed climbing. Flooring the *commanded* gradient keeps
|
||||
/// some load under the pedals while the *simulated* gradient stays true to
|
||||
/// the route, so the descent is still fast but the rider can contribute to
|
||||
/// it. Set to a large negative number to disable.
|
||||
#[serde(default = "default_descent_load_floor")]
|
||||
pub descent_load_floor_pct: f32,
|
||||
}
|
||||
|
||||
fn default_descent_load_floor() -> f32 {
|
||||
-1.0
|
||||
}
|
||||
|
||||
fn default_trainer_speed_weight() -> f32 {
|
||||
0.3
|
||||
}
|
||||
|
||||
impl Default for RiderConfig {
|
||||
@@ -121,6 +151,8 @@ impl Default for RiderConfig {
|
||||
drivetrain_efficiency: 0.97,
|
||||
air_density: 1.225,
|
||||
wheel_circumference_m: 2.105,
|
||||
trainer_speed_weight: default_trainer_speed_weight(),
|
||||
descent_load_floor_pct: default_descent_load_floor(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+79
-48
@@ -52,8 +52,8 @@ pub struct FitSummary {
|
||||
pub avg_power_w: Option<u16>,
|
||||
/// Peak power. `None` if no sample reported power.
|
||||
pub max_power_w: Option<u16>,
|
||||
/// Energy in kilocalories, from the trainer if it reports it and otherwise
|
||||
/// derived from mechanical work.
|
||||
/// Estimated rider energy expenditure in kilocalories, derived from
|
||||
/// measured mechanical work — see [`Aggregates::calories`].
|
||||
pub total_calories: Option<u16>,
|
||||
/// Number of BLE dropouts spanned (FR-8.5).
|
||||
pub gaps: usize,
|
||||
@@ -89,12 +89,9 @@ struct Aggregates {
|
||||
descent_m: f64,
|
||||
grade_sum: f64,
|
||||
grade_n: u32,
|
||||
/// Mechanical work, joules, integrated from power. The basis for calories
|
||||
/// when the trainer does not report energy directly.
|
||||
/// Mechanical work, joules, integrated from power. The sole basis for the
|
||||
/// calorie figure — see [`Aggregates::calories`].
|
||||
work_j: f64,
|
||||
/// Trainer-reported cumulative energy at the first and last sample.
|
||||
energy_start: Option<u16>,
|
||||
energy_end: Option<u16>,
|
||||
records: usize,
|
||||
}
|
||||
|
||||
@@ -125,19 +122,23 @@ impl Aggregates {
|
||||
self.total_distance_m() / (self.total_timer_ms as f64 / 1000.0)
|
||||
}
|
||||
|
||||
/// Calories.
|
||||
/// Calories, from measured work via [`bikecontrol_core::energy`] — the same
|
||||
/// model the live readout uses, so the file agrees with what the rider
|
||||
/// watched during the ride.
|
||||
///
|
||||
/// Prefers the trainer's own cumulative figure. Otherwise it uses the
|
||||
/// cycling convention that kilojoules of mechanical work and dietary
|
||||
/// kilocalories are numerically near-equal — human efficiency of roughly
|
||||
/// 24% and the 4.184 kJ/kcal conversion very nearly cancel. This is the
|
||||
/// same approximation Strava and Garmin apply to a power-meter ride.
|
||||
fn calories(&self) -> Option<u16> {
|
||||
match (self.energy_start, self.energy_end) {
|
||||
(Some(a), Some(b)) if b >= a && b > 0 => return Some(b - a),
|
||||
_ => {}
|
||||
}
|
||||
let kcal = clamp_u16(self.work_j / 1000.0);
|
||||
/// The trainer's own cumulative energy field is deliberately *not* used,
|
||||
/// even when present. FTMS specifies "Total Energy" in kilocalories but
|
||||
/// says nothing about whether it means mechanical or metabolic energy, and
|
||||
/// implementations disagree by a factor of four: some report kJ/4.184 (the
|
||||
/// rider as a perfect engine), others apply an efficiency factor, others
|
||||
/// report a figure with no documented basis at all. Measured power is the
|
||||
/// one input we can reason about, so it is the only one used.
|
||||
fn calories(&self, rider_kg: f32) -> Option<u16> {
|
||||
let kcal = clamp_u16(bikecontrol_core::energy::kcal(
|
||||
self.work_j,
|
||||
rider_kg,
|
||||
self.total_timer_ms as f64 / 1000.0,
|
||||
));
|
||||
(kcal > 0).then_some(kcal)
|
||||
}
|
||||
|
||||
@@ -215,7 +216,7 @@ pub fn encode_activity(log: &RawLog) -> Result<(Vec<u8>, FitSummary), FitError>
|
||||
total_ascent_m: clamp_u16(session_agg.ascent_m),
|
||||
avg_power_w: session_agg.avg_power(),
|
||||
max_power_w: session_agg.max_power,
|
||||
total_calories: session_agg.calories(),
|
||||
total_calories: session_agg.calories(log.start.rider_kg),
|
||||
gaps: log.gaps(end_ms).len(),
|
||||
recovered_from_crash: !log.clean_shutdown,
|
||||
skipped_log_lines: log.skipped_lines,
|
||||
@@ -358,12 +359,8 @@ fn aggregate(
|
||||
let s = &r.sample;
|
||||
if i == 0 {
|
||||
agg.start_distance_m = s.distance_m;
|
||||
agg.energy_start = s.energy_kcal;
|
||||
}
|
||||
agg.end_distance_m = s.distance_m;
|
||||
if s.energy_kcal.is_some() {
|
||||
agg.energy_end = s.energy_kcal;
|
||||
}
|
||||
|
||||
// Sample interval, for work integration. Clamped so that a long BLE
|
||||
// dropout does not silently attribute minutes of work to one sample.
|
||||
@@ -504,7 +501,7 @@ fn assemble(
|
||||
enc.write_message(
|
||||
local::LAP,
|
||||
mesg::LAP,
|
||||
&lap_message(lap_index as u16, agg, log.start.sub_sport, is_last),
|
||||
&lap_message(lap_index as u16, agg, log.start.sub_sport, log.start.rider_kg, is_last),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -513,7 +510,12 @@ fn assemble(
|
||||
enc.write_message(
|
||||
local::SESSION,
|
||||
mesg::SESSION,
|
||||
&session_message(session_agg, log.start.sub_sport, lap_aggs.len() as u16),
|
||||
&session_message(
|
||||
session_agg,
|
||||
log.start.sub_sport,
|
||||
log.start.rider_kg,
|
||||
lap_aggs.len() as u16,
|
||||
),
|
||||
);
|
||||
|
||||
let mut m = Message::new();
|
||||
@@ -641,7 +643,13 @@ fn record_message(r: &Resolved, mask: &FieldMask) -> Message {
|
||||
m
|
||||
}
|
||||
|
||||
fn lap_message(index: u16, agg: &Aggregates, sub_sport: u8, is_last: bool) -> Message {
|
||||
fn lap_message(
|
||||
index: u16,
|
||||
agg: &Aggregates,
|
||||
sub_sport: u8,
|
||||
rider_kg: f32,
|
||||
is_last: bool,
|
||||
) -> Message {
|
||||
let mut m = Message::new();
|
||||
m.set(lap::MESSAGE_INDEX, Value::Uint16(index));
|
||||
m.set(lap::TIMESTAMP, Value::Uint32(agg.end_fit));
|
||||
@@ -660,7 +668,7 @@ fn lap_message(index: u16, agg: &Aggregates, sub_sport: u8, is_last: bool) -> Me
|
||||
lap::TOTAL_DISTANCE,
|
||||
Value::Uint32(clamp_u32(agg.total_distance_m() * 100.0)),
|
||||
);
|
||||
m.set_opt(lap::TOTAL_CALORIES, agg.calories().map(Value::Uint16));
|
||||
m.set_opt(lap::TOTAL_CALORIES, agg.calories(rider_kg).map(Value::Uint16));
|
||||
m.set(
|
||||
lap::AVG_SPEED,
|
||||
Value::Uint16(clamp_u16(agg.avg_speed_mps() * 1000.0)),
|
||||
@@ -697,7 +705,7 @@ fn lap_message(index: u16, agg: &Aggregates, sub_sport: u8, is_last: bool) -> Me
|
||||
m
|
||||
}
|
||||
|
||||
fn session_message(agg: &Aggregates, sub_sport: u8, num_laps: u16) -> Message {
|
||||
fn session_message(agg: &Aggregates, sub_sport: u8, rider_kg: f32, num_laps: u16) -> Message {
|
||||
let mut m = Message::new();
|
||||
m.set(session::MESSAGE_INDEX, Value::Uint16(0));
|
||||
m.set(session::TIMESTAMP, Value::Uint32(agg.end_fit));
|
||||
@@ -718,7 +726,7 @@ fn session_message(agg: &Aggregates, sub_sport: u8, num_laps: u16) -> Message {
|
||||
session::TOTAL_DISTANCE,
|
||||
Value::Uint32(clamp_u32(agg.total_distance_m() * 100.0)),
|
||||
);
|
||||
m.set_opt(session::TOTAL_CALORIES, agg.calories().map(Value::Uint16));
|
||||
m.set_opt(session::TOTAL_CALORIES, agg.calories(rider_kg).map(Value::Uint16));
|
||||
m.set(
|
||||
session::AVG_SPEED,
|
||||
Value::Uint16(clamp_u16(agg.avg_speed_mps() * 1000.0)),
|
||||
@@ -905,7 +913,8 @@ mod tests {
|
||||
assert_eq!(summary.total_distance_m, 100.0);
|
||||
assert_eq!(summary.avg_power_w, Some(200));
|
||||
assert_eq!(summary.max_power_w, Some(200));
|
||||
// 200 W for ten one-second intervals = 2000 J = 2 kJ ~ 2 kcal.
|
||||
// 200 W for ten one-second intervals = 2000 J = 2 kJ ~ 2 kcal. The
|
||||
// fixture log carries no rider mass, so there is no resting term.
|
||||
assert_eq!(summary.total_calories, Some(2));
|
||||
}
|
||||
|
||||
@@ -1182,25 +1191,47 @@ mod tests {
|
||||
assert_eq!(session.total_distance_m(), 600.0);
|
||||
}
|
||||
|
||||
/// A trainer's own energy field is ignored, however confidently it is
|
||||
/// reported: implementations disagree by a factor of four about what it
|
||||
/// means, and measured power does not. 200 W for a minute is 12 kJ of
|
||||
/// work — about 12 kcal — not the 200 the trainer claims.
|
||||
#[test]
|
||||
fn trainer_reported_energy_is_preferred_for_calories() {
|
||||
let entries = vec![
|
||||
LogEntry::Sample(Sample {
|
||||
elapsed_ms: 0,
|
||||
power_w: Some(200),
|
||||
energy_kcal: Some(10),
|
||||
..Default::default()
|
||||
}),
|
||||
LogEntry::Sample(Sample {
|
||||
elapsed_ms: 60_000,
|
||||
power_w: Some(200),
|
||||
energy_kcal: Some(210),
|
||||
..Default::default()
|
||||
}),
|
||||
LogEntry::End { at_ms: 60_000 },
|
||||
];
|
||||
fn trainer_reported_energy_is_ignored_in_favour_of_measured_work() {
|
||||
let mut entries: Vec<LogEntry> = (0..=60)
|
||||
.map(|i| {
|
||||
LogEntry::Sample(Sample {
|
||||
elapsed_ms: i * 1000,
|
||||
power_w: Some(200),
|
||||
// A trainer insisting the rider burned 200 kcal in a minute.
|
||||
energy_kcal: Some(10 + (i * 200 / 60) as u16),
|
||||
..Default::default()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
entries.push(LogEntry::End { at_ms: 60_000 });
|
||||
let (_, summary) = encode_activity(&log_with(entries)).unwrap();
|
||||
assert_eq!(summary.total_calories, Some(200));
|
||||
assert_eq!(summary.total_calories, Some(11));
|
||||
}
|
||||
|
||||
/// With a rider mass recorded, the hour spent riding costs something even
|
||||
/// beyond the pedalling: 1 MET of resting metabolism on top of the work.
|
||||
#[test]
|
||||
fn a_recorded_rider_mass_adds_the_resting_burn() {
|
||||
let mut entries: Vec<LogEntry> = (0..=60)
|
||||
.map(|i| {
|
||||
LogEntry::Sample(Sample {
|
||||
elapsed_ms: i * 1000,
|
||||
power_w: Some(200),
|
||||
..Default::default()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
entries.push(LogEntry::End { at_ms: 60_000 });
|
||||
let mut log = log_with(entries);
|
||||
log.start.rider_kg = 75.0;
|
||||
let (_, summary) = encode_activity(&log).unwrap();
|
||||
// ~11.5 kcal of work plus 75 kcal/h for one minute.
|
||||
assert_eq!(summary.total_calories, Some(13));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -107,6 +107,11 @@ pub struct SessionStart {
|
||||
/// Device serial. Zero means "unset" (the FIT base type is `uint32z`).
|
||||
#[serde(default)]
|
||||
pub serial_number: u32,
|
||||
/// Rider mass in kilograms, for the resting half of the calorie estimate
|
||||
/// (`bikecontrol_core::energy`). Zero means unknown — as it will be in any
|
||||
/// journal written before this field existed — and simply drops that term.
|
||||
#[serde(default)]
|
||||
pub rider_kg: f32,
|
||||
/// Format version of this log.
|
||||
#[serde(default)]
|
||||
pub log_format: u16,
|
||||
@@ -138,6 +143,7 @@ impl Default for SessionStart {
|
||||
product_name: default_product_name(),
|
||||
software_version: default_software_version(),
|
||||
serial_number: 0,
|
||||
rider_kg: 0.0,
|
||||
log_format: LOG_FORMAT_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,9 @@ pub struct RecorderOptions {
|
||||
pub software_version: u16,
|
||||
/// Device serial number. Zero means unset.
|
||||
pub serial_number: u32,
|
||||
/// Rider mass in kilograms, recorded so the calorie estimate in the
|
||||
/// finished activity can include the resting term. Zero means unknown.
|
||||
pub rider_kg: f32,
|
||||
}
|
||||
|
||||
impl Default for RecorderOptions {
|
||||
@@ -54,6 +57,7 @@ impl Default for RecorderOptions {
|
||||
product_name: "BikeControl".to_string(),
|
||||
software_version: 100,
|
||||
serial_number: 0,
|
||||
rider_kg: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,6 +138,7 @@ impl Recorder {
|
||||
product_name: opts.product_name.clone(),
|
||||
software_version: opts.software_version,
|
||||
serial_number: opts.serial_number,
|
||||
rider_kg: opts.rider_kg,
|
||||
log_format: LOG_FORMAT_VERSION,
|
||||
};
|
||||
|
||||
|
||||
+31
-2
@@ -20,6 +20,7 @@ SUBCOMMANDS:
|
||||
inspect <ADDR> Connect and dump every service, characteristic and capability
|
||||
monitor <ADDR> Stream Indoor Bike Data as raw hex alongside decoded fields
|
||||
set <ADDR> <TARGET> Take control and apply a target, then reset the trainer to zero
|
||||
zwift <ADDR> Talk to Zwift's custom service: handshake, then log every frame
|
||||
|
||||
TARGET (for `set`):
|
||||
gradient=<PCT> SetTargetInclination (0x03), e.g. gradient=4.5
|
||||
@@ -29,16 +30,23 @@ TARGET (for `set`):
|
||||
|
||||
OPTIONS:
|
||||
--secs <N> scan/monitor duration, or how long `set` holds the target (default:
|
||||
scan 6, monitor 30, set 15)
|
||||
scan 6, monitor 30, set 15, zwift 60)
|
||||
--all `scan`: list every peripheral, not just fitness machines
|
||||
--name <SUBSTR> use in place of <ADDR> to match on advertised name
|
||||
--no-handshake `zwift`: subscribe and listen without writing RideOn
|
||||
--buttons `zwift`: collapse the ~10 Hz button stream to one line per
|
||||
press and release, for mapping bits to physical buttons
|
||||
-v, --verbose debug-level logging, including every raw BLE frame (NFR-8)
|
||||
-h, --help this text
|
||||
|
||||
ADDR is the address as printed by `scan` (on Linux, AA:BB:CC:DD:EE:FF).
|
||||
|
||||
SAFETY: `set` always finishes by zeroing the gradient, dropping resistance to the
|
||||
trainer's minimum and issuing Reset + Stop (SAF-2), including on Ctrl-C.
|
||||
trainer's minimum and issuing Reset + Stop (SAF-2), including on Ctrl-C. `zwift` is
|
||||
read-mostly: the only thing it ever writes is the RideOn handshake.
|
||||
|
||||
The Click must be unlocked in the free Zwift app first — pair it there, hold it for
|
||||
~30 s, then quit Zwift. The unlock lasts about a day (REQUIREMENTS.md §2.3).
|
||||
";
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
@@ -62,6 +70,17 @@ pub enum Command {
|
||||
simulation: bool,
|
||||
hold: Duration,
|
||||
},
|
||||
/// Phase 3 / TASK-0: exercise Zwift's custom service on whatever advertises
|
||||
/// it — a Click, or the trainer itself.
|
||||
Zwift {
|
||||
device: Device,
|
||||
duration: Duration,
|
||||
/// True to listen only, writing nothing at all.
|
||||
no_handshake: bool,
|
||||
/// True to print one line per button state change instead of every
|
||||
/// frame — the mode for mapping bits to physical buttons.
|
||||
buttons_only: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// How the user identified the trainer.
|
||||
@@ -83,6 +102,8 @@ pub fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
|
||||
let mut verbose = false;
|
||||
let mut secs: Option<u64> = None;
|
||||
let mut all = false;
|
||||
let mut no_handshake = false;
|
||||
let mut buttons_only = false;
|
||||
let mut name: Option<String> = None;
|
||||
let mut help = false;
|
||||
let mut positional: Vec<String> = Vec::new();
|
||||
@@ -94,6 +115,8 @@ pub fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
|
||||
"-h" | "--help" | "help" => help = true,
|
||||
"-v" | "--verbose" => verbose = true,
|
||||
"--all" => all = true,
|
||||
"--no-handshake" => no_handshake = true,
|
||||
"--buttons" => buttons_only = true,
|
||||
"--secs" | "--seconds" => {
|
||||
i += 1;
|
||||
let v = args
|
||||
@@ -167,6 +190,12 @@ pub fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
|
||||
hold: Duration::from_secs(secs.unwrap_or(15)),
|
||||
}
|
||||
}
|
||||
"zwift" => Command::Zwift {
|
||||
device: device(&positional, 1)?,
|
||||
duration: Duration::from_secs(secs.unwrap_or(60)),
|
||||
no_handshake,
|
||||
buttons_only,
|
||||
},
|
||||
other => bail!("unknown subcommand {other:?} — run `probe --help`"),
|
||||
};
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ use bikecontrol_ble::client::{ControlOutcome, FtmsClient, FtmsConfig, FtmsEvent}
|
||||
use bikecontrol_ble::control_point::ResultCode;
|
||||
use bikecontrol_ble::indoor_bike_data::{self, hex, IndoorBikeData};
|
||||
use bikecontrol_ble::scan::{self, DiscoveredDevice, ScanKind, TrainerSelector};
|
||||
use bikecontrol_ble::{uuids, FtmsError};
|
||||
use bikecontrol_ble::{uuids, zwift, FtmsError};
|
||||
use bikecontrol_core::types::ControlTarget;
|
||||
use btleplug::api::{CharPropFlags, Characteristic, Peripheral as _};
|
||||
use btleplug::platform::Peripheral;
|
||||
@@ -584,6 +584,394 @@ fn report_outcome(outcome: &Result<ControlOutcome, FtmsError>, simulation: bool)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// zwift
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// TASK-0: connect to whatever advertises Zwift's custom service, try the
|
||||
/// `RideOn` handshake unencrypted, and log every frame that comes back.
|
||||
///
|
||||
/// Points at either end of the open question. Against a **Click v2** it tests
|
||||
/// A-3 — whether the unencrypted path still yields button events on a v2.
|
||||
/// Against the **trainer** it asks what the D100 is doing with a Zwift service
|
||||
/// at all; if it is the virtual-shifting endpoint, the Click may belong to the
|
||||
/// trainer rather than to us.
|
||||
///
|
||||
/// Nothing here interprets a frame as a gear change or drives resistance. It
|
||||
/// prints bytes. Everything the protocol module claims (§2.3.1) is a hypothesis
|
||||
/// until the hex on screen agrees with it.
|
||||
pub async fn zwift_cmd(
|
||||
device: &Device,
|
||||
duration: Duration,
|
||||
no_handshake: bool,
|
||||
buttons_only: bool,
|
||||
scan_timeout: Duration,
|
||||
) -> Result<()> {
|
||||
let peripheral = connect(device, scan_timeout).await?;
|
||||
|
||||
if let Some(d) = scan::describe(&peripheral).await {
|
||||
println!("Connected to {} ({})", d.address, d.label());
|
||||
match d.zwift_kind() {
|
||||
Some(kind) => println!("Advertised as: {}", kind.describe()),
|
||||
None => println!(
|
||||
"No Zwift manufacturer data in the advertisement — this is not a controller,\n\
|
||||
or it was already connected when we found it."
|
||||
),
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
// A Click v2 carries 0xFC82; the trainer carries 00000001-19CA-…. Take
|
||||
// whichever is present rather than assuming, because which one a device
|
||||
// speaks is itself a finding.
|
||||
let service = zwift::SERVICES
|
||||
.iter()
|
||||
.find_map(|want| peripheral.services().into_iter().find(|s| s.uuid == *want));
|
||||
let Some(service) = service else {
|
||||
println!("!! This peripheral exposes no known Zwift service. Looked for:");
|
||||
for want in zwift::SERVICES {
|
||||
println!(" {want}{}", zwift_named(want));
|
||||
}
|
||||
println!("!! Services it does expose:");
|
||||
for s in peripheral.services() {
|
||||
println!(" {}{}", s.uuid, named(s.uuid));
|
||||
}
|
||||
disconnect(&peripheral).await;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
println!("=== Zwift service {}{} ===\n", service.uuid, zwift_named(service.uuid));
|
||||
for ch in &service.characteristics {
|
||||
println!(
|
||||
" char {}{}\n properties: {}",
|
||||
ch.uuid,
|
||||
zwift_named(ch.uuid),
|
||||
properties(ch.properties)
|
||||
);
|
||||
if ch.properties.contains(CharPropFlags::READ) {
|
||||
match peripheral.read(ch).await {
|
||||
Ok(v) => println!(" value: {} {}", hex(&v), as_text(&v)),
|
||||
Err(e) => println!(" value: <unreadable: {e}>"),
|
||||
}
|
||||
}
|
||||
}
|
||||
println!();
|
||||
|
||||
// Subscribe to everything that can talk before writing anything, so the
|
||||
// handshake reply cannot land before we are listening.
|
||||
let mut notifications = peripheral.notifications().await?;
|
||||
let listening: Vec<Characteristic> = service
|
||||
.characteristics
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
c.properties
|
||||
.intersects(CharPropFlags::NOTIFY | CharPropFlags::INDICATE)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
if listening.is_empty() {
|
||||
println!("!! Nothing in this service notifies or indicates — there is nothing to listen to.");
|
||||
disconnect(&peripheral).await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for ch in &listening {
|
||||
match peripheral.subscribe(ch).await {
|
||||
Ok(()) => println!("Subscribed to {}{}", ch.uuid, zwift_named(ch.uuid)),
|
||||
Err(e) => println!("Could not subscribe to {}: {e}", ch.uuid),
|
||||
}
|
||||
}
|
||||
println!();
|
||||
|
||||
let start = Instant::now();
|
||||
let mut frames: u64 = 0;
|
||||
|
||||
if no_handshake {
|
||||
println!("--no-handshake: writing nothing, just listening.\n");
|
||||
} else if let Some(sync_rx) = writable(&service) {
|
||||
if sync_rx.uuid != zwift::SYNC_RX {
|
||||
println!(
|
||||
"Sync RX ({}) is absent; using {} instead, which is the only writable\n\
|
||||
characteristic in this service.\n",
|
||||
zwift::SYNC_RX,
|
||||
sync_rx.uuid
|
||||
);
|
||||
}
|
||||
frames += handshake(&peripheral, &sync_rx, &mut notifications, start).await?;
|
||||
} else {
|
||||
println!("Nothing in this service is writable — cannot hand shake. Listening only.\n");
|
||||
}
|
||||
|
||||
if buttons_only {
|
||||
println!(
|
||||
"=== BUTTON MAPPING — GO ===\n\n\
|
||||
Press one button at a time, holding each for about 2 s with a gap between.\n\
|
||||
Only state changes are printed, so each press is one PRESS and one RELEASE.\n\
|
||||
{} s to go; Ctrl-C to stop early.\n",
|
||||
duration.saturating_sub(start.elapsed()).as_secs()
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"Listening for {} s. Press the Click's paddles and D-pad; press Ctrl-C to stop.\n",
|
||||
duration.as_secs()
|
||||
);
|
||||
}
|
||||
|
||||
let deadline = tokio::time::sleep(duration.saturating_sub(start.elapsed()));
|
||||
tokio::pin!(deadline);
|
||||
|
||||
// Only meaningful in --buttons mode: the mask as of the previous frame, so
|
||||
// the ~10 Hz repeat while a button is held collapses to one line.
|
||||
let mut last_mask: Option<u32> = None;
|
||||
let mut presses: u64 = 0;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = &mut deadline => break,
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
println!("\nInterrupted.");
|
||||
break;
|
||||
}
|
||||
n = notifications.next() => {
|
||||
let Some(n) = n else {
|
||||
println!("\nNotification stream ended (the device disconnected).");
|
||||
break;
|
||||
};
|
||||
frames += 1;
|
||||
let elapsed = start.elapsed().as_secs_f32();
|
||||
|
||||
if buttons_only {
|
||||
if let Some(mask) = button_mask(&n.value) {
|
||||
if last_mask != Some(mask.raw) {
|
||||
last_mask = Some(mask.raw);
|
||||
if !mask.is_idle() {
|
||||
presses += 1;
|
||||
}
|
||||
print_transition(&mask, elapsed, presses);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
print_zwift_frame(n.uuid, &n.value, elapsed, frames);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n{frames} frame(s) in {:.1} s.", start.elapsed().as_secs_f32());
|
||||
if frames == 0 {
|
||||
println!(
|
||||
"Nothing arrived. Either the handshake is wrong, or the unlock has expired —\n\
|
||||
re-pair in the Zwift app and try again within the day (§2.3)."
|
||||
);
|
||||
}
|
||||
|
||||
for ch in &listening {
|
||||
let _ = peripheral.unsubscribe(ch).await;
|
||||
}
|
||||
disconnect(&peripheral).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write each candidate handshake in turn, waiting briefly for a reply after
|
||||
/// each. Returns how many frames arrived during the attempts.
|
||||
///
|
||||
/// Which two bytes follow `RideOn` is the unverified part of §2.3.1, so this
|
||||
/// tries them rather than betting on one. It stops at the first `RideOn` reply
|
||||
/// — that is the answer to TASK-0, and writing further handshakes after a
|
||||
/// successful one would only confuse the session.
|
||||
async fn handshake(
|
||||
peripheral: &Peripheral,
|
||||
sync_rx: &Characteristic,
|
||||
notifications: &mut (impl futures::Stream<Item = btleplug::api::ValueNotification> + Unpin),
|
||||
start: Instant,
|
||||
) -> Result<u64> {
|
||||
// WriteWithoutResponse when the characteristic allows it: the Zwift
|
||||
// references use it, and a device that never sends a write response would
|
||||
// otherwise stall us for the full BLE timeout.
|
||||
let write_type = if sync_rx
|
||||
.properties
|
||||
.contains(CharPropFlags::WRITE_WITHOUT_RESPONSE)
|
||||
{
|
||||
btleplug::api::WriteType::WithoutResponse
|
||||
} else {
|
||||
btleplug::api::WriteType::WithResponse
|
||||
};
|
||||
|
||||
println!("=== Handshake ===\n");
|
||||
let mut frames = 0;
|
||||
|
||||
for (label, suffix) in zwift::HANDSHAKE_CANDIDATES {
|
||||
let frame = zwift::handshake(suffix);
|
||||
println!("-> {} : {}", label, hex(&frame));
|
||||
|
||||
if let Err(e) = peripheral.write(sync_rx, &frame, write_type).await {
|
||||
println!(" write failed: {e}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Long enough for a device that is going to answer to have answered.
|
||||
let window = tokio::time::sleep(Duration::from_millis(1500));
|
||||
tokio::pin!(window);
|
||||
let mut answered = false;
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = &mut window => break,
|
||||
n = notifications.next() => {
|
||||
let Some(n) = n else { break };
|
||||
frames += 1;
|
||||
print_zwift_frame(n.uuid, &n.value, start.elapsed().as_secs_f32(), frames);
|
||||
if zwift::is_ride_on_reply(&n.value) {
|
||||
answered = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if answered {
|
||||
println!("\n RideOn acknowledged — this is the handshake the device wants.");
|
||||
println!(" TASK-0 answered: the unencrypted path is open.\n");
|
||||
return Ok(frames);
|
||||
}
|
||||
println!(" no RideOn reply.\n");
|
||||
}
|
||||
|
||||
println!(
|
||||
"None of the candidate handshakes drew a RideOn reply. Either the suffix is\n\
|
||||
something else, or this device requires the encrypted handshake (§2.3.1).\n\
|
||||
Frames may still arrive unprompted — keep watching.\n"
|
||||
);
|
||||
Ok(frames)
|
||||
}
|
||||
|
||||
/// Print one Zwift frame: raw hex first, then whatever we think it means.
|
||||
fn print_zwift_frame(uuid: Uuid, raw: &[u8], elapsed: f32, index: u64) {
|
||||
println!(
|
||||
"[{elapsed:7.2}s] #{index} {}{}: {}",
|
||||
uuid,
|
||||
zwift_named(uuid),
|
||||
hex(raw)
|
||||
);
|
||||
|
||||
if zwift::is_ride_on_reply(raw) {
|
||||
println!(" RideOn reply, {} byte(s) total", raw.len());
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(frame) = zwift::parse_frame(raw) else {
|
||||
println!(" empty frame");
|
||||
return;
|
||||
};
|
||||
println!(" type: {}", frame.kind.describe());
|
||||
|
||||
match frame.kind {
|
||||
zwift::MessageType::ButtonBitmask => match zwift::decode_button_bitmask(frame.payload) {
|
||||
Ok(m) if m.is_idle() => println!(" mask 0x{:08x} (idle)", m.raw),
|
||||
Ok(m) => {
|
||||
let bits: Vec<String> = m.pressed_bits().iter().map(|b| b.to_string()).collect();
|
||||
println!(
|
||||
" mask 0x{:08x} PRESSED: bit {}",
|
||||
m.raw,
|
||||
bits.join(" + bit ")
|
||||
);
|
||||
}
|
||||
Err(e) => println!(" payload is not a varint ({e})"),
|
||||
},
|
||||
zwift::MessageType::ClickButtons => match zwift::decode_click_buttons(frame.payload) {
|
||||
Ok(b) => println!(
|
||||
" up: {} down: {}",
|
||||
pressed(b.up_pressed),
|
||||
pressed(b.down_pressed)
|
||||
),
|
||||
Err(e) => println!(" payload is not two varints ({e}) — 0x37 is not what we assume"),
|
||||
},
|
||||
zwift::MessageType::Battery => match zwift::decode_battery(frame.payload) {
|
||||
Ok(Some(pct)) => println!(" battery: {pct}%"),
|
||||
Ok(None) => println!(" battery: no field in payload"),
|
||||
Err(e) => println!(" could not decode ({e})"),
|
||||
},
|
||||
_ => {
|
||||
// Unknown and controller frames: show the protobuf structure if it
|
||||
// has one, since that is the fastest route to naming the fields.
|
||||
if let Ok(fields) = zwift::decode_varint_fields(frame.payload) {
|
||||
if !fields.is_empty() {
|
||||
let rendered: Vec<String> = fields
|
||||
.iter()
|
||||
.map(|(f, v)| format!("field {f} = {v}"))
|
||||
.collect();
|
||||
println!(" varints: {}", rendered.join(", "));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The button mask in `raw`, or `None` if this is not a button frame.
|
||||
fn button_mask(raw: &[u8]) -> Option<zwift::ButtonBitmask> {
|
||||
let frame = zwift::parse_frame(raw)?;
|
||||
if frame.kind != zwift::MessageType::ButtonBitmask {
|
||||
return None;
|
||||
}
|
||||
zwift::decode_button_bitmask(frame.payload).ok()
|
||||
}
|
||||
|
||||
/// One line per button state change, for `--buttons`.
|
||||
fn print_transition(mask: &zwift::ButtonBitmask, elapsed: f32, presses: u64) {
|
||||
if mask.is_idle() {
|
||||
println!("[{elapsed:7.2}s] RELEASE --- mask 0x{:08x}", mask.raw);
|
||||
return;
|
||||
}
|
||||
// Name the button where we can, but always show the bit — an unmapped bit
|
||||
// is exactly the thing this tool exists to surface.
|
||||
let held: Vec<String> = mask
|
||||
.pressed_bits()
|
||||
.iter()
|
||||
.map(|b| match zwift::Button::from_bit(*b) {
|
||||
Some(button) => format!("{} (bit {b})", button.label()),
|
||||
None => format!("UNMAPPED bit {b}"),
|
||||
})
|
||||
.collect();
|
||||
println!(
|
||||
"[{elapsed:7.2}s] PRESS #{presses:<3} {:<24} mask 0x{:08x}",
|
||||
held.join(" + "),
|
||||
mask.raw
|
||||
);
|
||||
}
|
||||
|
||||
/// The characteristic to write the handshake to: the documented sync RX if the
|
||||
/// device has it, otherwise the first writable one. On a service whose layout
|
||||
/// we have never seen, "the only thing that accepts a write" is the best
|
||||
/// available guess.
|
||||
fn writable(service: &btleplug::api::Service) -> Option<Characteristic> {
|
||||
let writable_flags = CharPropFlags::WRITE | CharPropFlags::WRITE_WITHOUT_RESPONSE;
|
||||
service
|
||||
.characteristics
|
||||
.iter()
|
||||
.find(|c| c.uuid == zwift::SYNC_RX && c.properties.intersects(writable_flags))
|
||||
.or_else(|| {
|
||||
service
|
||||
.characteristics
|
||||
.iter()
|
||||
.find(|c| c.properties.intersects(writable_flags))
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn pressed(b: bool) -> &'static str {
|
||||
if b {
|
||||
"PRESSED"
|
||||
} else {
|
||||
"released"
|
||||
}
|
||||
}
|
||||
|
||||
/// Name a UUID, checking Zwift's custom space as well as the SIG's.
|
||||
fn zwift_named(uuid: Uuid) -> String {
|
||||
zwift::well_known_name(uuid)
|
||||
.map(|n| format!(" ({n})"))
|
||||
.unwrap_or_else(|| named(uuid))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared plumbing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -49,6 +49,14 @@ async fn main() -> Result<()> {
|
||||
simulation,
|
||||
hold,
|
||||
} => commands::set(&device, target, simulation, hold, SCAN_TIMEOUT).await,
|
||||
cli::Command::Zwift {
|
||||
device,
|
||||
duration,
|
||||
no_handshake,
|
||||
buttons_only,
|
||||
} => {
|
||||
commands::zwift_cmd(&device, duration, no_handshake, buttons_only, SCAN_TIMEOUT).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user