Ride the drivetrain, command the load in watts
Speed now comes from the drivetrain and the load from the road, which is the way round a bike actually works. Speed is cadence x development, filtered lightly. Power, not cadence, decides whether the rider is driving it: on a direct-drive trainer the flywheel keeps the cranks turning after they stop, so cadence alone reads a healthy 80 rpm for someone doing nothing. Below 15 W the speed runs down to whatever the gradient sustains on no power - zero uphill, a real freewheeling speed on a descent. Stopping on a 3.5% climb used to settle at 22 km/h and stay there, because the model wanted to decelerate and a blend toward the flywheel speed outvoted it; that blend is gone. The D100 sends no cadence over FTMS - it is a rebadged Magene T110 with cadence disabled in firmware (qdomyos-zwift#3282) - so it is inferred from wheel speed, which one sprocket and no freewheel make exact. Its Zwift channel does carry cadence, and is now greeted with RideOn and subscribed on every notifying characteristic, so a measured value is used where one arrives. The load is commanded as power, not gradient. The trainer declares 50-600 W in 1 W steps against 0-6% inclination in 0.1% steps refusing negatives, and whether it acts on 0x11 at all is still unconfirmed. Its power target is a ceiling rather than a setpoint, which is very nearly what a road is: exceed it and the surplus becomes speed. Gravity travels on the same channel as watts, so nothing is lost by leaving 0x11 alone. LoadChannel keeps the gradient path selectable and tested. Virtual shifting reaches the trainer for the first time. The physics load model was written but never called, and a paddle press both shifted a gear in Rust and nudged the gradient in the webview - the shift silently, the tilt visibly, so the paddles looked like a gradient trim. Also: a fixed 12 W drivetrain loss, held as a power because that is how it presents; crank length, so a gear can be reported as the force it puts under the foot; gear and pedal force on the ride screen; a drag-race profile for testing gearing on the flat. Two readout bugs fixed on the way. The rolling windows were trimmed by timestamp but fed on a fixed timer, so every second spent on the ride screen before starting pushed samples at t=0 that could never expire - speed read a fraction of the truth for the first 45 s. And the headline speed was a 45 s mean, which took most of a minute to show a gear change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+178
-18
@@ -26,8 +26,78 @@ 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};
|
||||
use crate::scan::{self, ScanKind};
|
||||
use crate::zwift::{self, Button, ButtonTracker, PodId};
|
||||
|
||||
/// How to pick a controller pod out of a scan.
|
||||
///
|
||||
/// Deliberately not [`crate::scan::TrainerSelector`]. A pair of Click pods
|
||||
/// advertise the *same* local name — both of ours are plain `Zwift Click` — so
|
||||
/// name matching cannot tell them apart and whichever answered first won. That
|
||||
/// is the whole reason the connection felt arbitrary: ask for "the Click" and
|
||||
/// you get a coin toss between the pod with the D-pad and the one without.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PodSelector {
|
||||
/// A specific pod, by address. What the app uses once a scan has seen the
|
||||
/// pair, because an address cannot be confused with its twin.
|
||||
Address(String),
|
||||
/// The pod whose manufacturer-data type byte says it is this one (§2.3.1).
|
||||
/// Used before either pod has been seen by a scan.
|
||||
Pod(PodId),
|
||||
/// Any Click pod at all. The fallback for hardware whose type byte we do
|
||||
/// not recognise — better a controller in the wrong slot than none.
|
||||
Any,
|
||||
}
|
||||
|
||||
impl PodSelector {
|
||||
/// Does this peripheral match?
|
||||
pub fn matches(&self, d: &scan::DiscoveredDevice) -> bool {
|
||||
self.matches_parts(&d.address, d.zwift_kind())
|
||||
}
|
||||
|
||||
/// The matching rule, factored out so it can be unit-tested without a
|
||||
/// `PeripheralId` (which only the platform backend can construct).
|
||||
pub(crate) fn matches_parts(&self, address: &str, kind: Option<zwift::DeviceKind>) -> bool {
|
||||
match self {
|
||||
// An address is matched without insisting on the manufacturer data:
|
||||
// a pod that is mid-connection may not be advertising it, and the
|
||||
// address alone already identifies exactly one peripheral.
|
||||
PodSelector::Address(a) => address.eq_ignore_ascii_case(a),
|
||||
PodSelector::Pod(pod) => kind.and_then(|k| k.pod_id()) == Some(*pod),
|
||||
PodSelector::Any => kind.is_some_and(|k| k.is_click()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable, for error messages the rider will actually read.
|
||||
pub fn describe(&self) -> String {
|
||||
match self {
|
||||
PodSelector::Address(a) => format!("Click pod at {a}"),
|
||||
PodSelector::Pod(p) => format!("the {} Click pod", p.symbol()),
|
||||
PodSelector::Any => "any Click pod".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Which pod this asks for, when it asks for a particular one at all.
|
||||
pub fn pod(&self) -> Option<PodId> {
|
||||
match self {
|
||||
PodSelector::Pod(p) => Some(*p),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan until a pod matching `selector` appears, or `timeout` elapses.
|
||||
pub async fn find_pod(
|
||||
adapter: &Adapter,
|
||||
selector: &PodSelector,
|
||||
timeout: Duration,
|
||||
) -> Result<Peripheral, FtmsError> {
|
||||
// Unfiltered: a Click advertises Zwift's own service, never FTMS.
|
||||
scan::find_matching(adapter, ScanKind::All, timeout, &selector.describe(), |d| {
|
||||
selector.matches(d)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Tunables for [`ClickClient`].
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -55,6 +125,11 @@ pub enum ClickEvent {
|
||||
Connected {
|
||||
address: String,
|
||||
name: Option<String>,
|
||||
/// Which pod this turned out to be, from its own advertisement — not
|
||||
/// from what was asked for. Connecting by address says nothing about
|
||||
/// which paddle the pod carries, and the app would otherwise have to
|
||||
/// assume it got what it requested.
|
||||
pod: Option<PodId>,
|
||||
},
|
||||
/// The link dropped. Any held button has already been reported as released.
|
||||
/// The actor is retrying — this is not terminal.
|
||||
@@ -84,18 +159,36 @@ enum Cmd {
|
||||
pub struct ClickClient {
|
||||
cmd_tx: mpsc::Sender<Cmd>,
|
||||
events_tx: broadcast::Sender<ClickEvent>,
|
||||
address: String,
|
||||
name: Option<String>,
|
||||
pod: Option<PodId>,
|
||||
}
|
||||
|
||||
impl ClickClient {
|
||||
/// The pod's address. Carried on the handle rather than left to the event
|
||||
/// stream because [`ClickEvent::Connected`] for the *first* session is sent
|
||||
/// before the caller has had a chance to subscribe — a caller that learned
|
||||
/// its identity only from events would sit there believing it had connected
|
||||
/// to nothing.
|
||||
pub fn address(&self) -> &str {
|
||||
&self.address
|
||||
}
|
||||
|
||||
pub fn name(&self) -> Option<&str> {
|
||||
self.name.as_deref()
|
||||
}
|
||||
|
||||
/// Which pod of a pair this turned out to be, where it said so (§2.3.1).
|
||||
pub fn pod(&self) -> Option<PodId> {
|
||||
self.pod
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
pub async fn connect(selector: PodSelector, config: ClickConfig) -> Result<Self, FtmsError> {
|
||||
let adapter = scan::default_adapter().await?;
|
||||
Self::connect_with_adapter(adapter, selector, config).await
|
||||
}
|
||||
@@ -108,7 +201,7 @@ impl ClickClient {
|
||||
/// 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,
|
||||
selector: PodSelector,
|
||||
config: ClickConfig,
|
||||
cancel: impl Future<Output = ()>,
|
||||
) -> Result<Option<Self>, FtmsError> {
|
||||
@@ -137,7 +230,7 @@ impl ClickClient {
|
||||
/// As [`ClickClient::connect`], but on a caller-supplied adapter.
|
||||
pub async fn connect_with_adapter(
|
||||
adapter: Adapter,
|
||||
selector: TrainerSelector,
|
||||
selector: PodSelector,
|
||||
config: ClickConfig,
|
||||
) -> Result<Self, FtmsError> {
|
||||
Self::connect_on(adapter, selector, config, &InFlight::default()).await
|
||||
@@ -145,7 +238,7 @@ impl ClickClient {
|
||||
|
||||
async fn connect_on(
|
||||
adapter: Adapter,
|
||||
selector: TrainerSelector,
|
||||
selector: PodSelector,
|
||||
config: ClickConfig,
|
||||
in_flight: &InFlight,
|
||||
) -> Result<Self, FtmsError> {
|
||||
@@ -153,21 +246,34 @@ impl ClickClient {
|
||||
let (cmd_tx, cmd_rx) = mpsc::channel(8);
|
||||
|
||||
let (session, notifications) = open_session(&adapter, &selector, &config, in_flight).await?;
|
||||
let (address, name, pod) = (session.address.clone(), session.name.clone(), session.pod);
|
||||
// Sent for symmetry with a reconnect. Nobody is subscribed yet, which is
|
||||
// why the same three facts also ride out on the handle below.
|
||||
let _ = events_tx.send(ClickEvent::Connected {
|
||||
address: session.address.clone(),
|
||||
name: session.name.clone(),
|
||||
address: address.clone(),
|
||||
name: name.clone(),
|
||||
pod,
|
||||
});
|
||||
|
||||
let actor = Actor {
|
||||
adapter,
|
||||
selector,
|
||||
// Reconnect to *this* pod, not to whatever now answers the original
|
||||
// description. Chasing `the − pod` after a drop could land on the
|
||||
// twin if the type bytes are not what we think they are, and then
|
||||
// both slots would be the same peripheral. Only an address we
|
||||
// actually read is an improvement on what we were asked for.
|
||||
selector: if address.is_empty() {
|
||||
selector
|
||||
} else {
|
||||
PodSelector::Address(address.clone())
|
||||
},
|
||||
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 })
|
||||
Ok(Self { cmd_tx, events_tx, address, name, pod })
|
||||
}
|
||||
|
||||
/// Subscribe to controller events. Late subscribers see only what arrives
|
||||
@@ -204,21 +310,23 @@ struct Session {
|
||||
peripheral: Peripheral,
|
||||
address: String,
|
||||
name: Option<String>,
|
||||
/// Which pod this is, as it advertised itself.
|
||||
pod: Option<PodId>,
|
||||
subscribed: Vec<Characteristic>,
|
||||
}
|
||||
|
||||
/// Find the pod, connect, subscribe, and complete the `RideOn` handshake.
|
||||
async fn open_session(
|
||||
adapter: &Adapter,
|
||||
selector: &TrainerSelector,
|
||||
selector: &PodSelector,
|
||||
config: &ClickConfig,
|
||||
in_flight: &InFlight,
|
||||
) -> Result<(Session, Notifications), FtmsError> {
|
||||
let peripheral = scan::find_peripheral(adapter, selector, config.scan_timeout).await?;
|
||||
let peripheral = find_pod(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 {
|
||||
match setup_session(peripheral.clone(), selector.pod()).await {
|
||||
Ok(session) => {
|
||||
in_flight.released();
|
||||
Ok(session)
|
||||
@@ -235,7 +343,10 @@ async fn open_session(
|
||||
}
|
||||
}
|
||||
|
||||
async fn setup_session(peripheral: Peripheral) -> Result<(Session, Notifications), FtmsError> {
|
||||
async fn setup_session(
|
||||
peripheral: Peripheral,
|
||||
asked_for: Option<PodId>,
|
||||
) -> Result<(Session, Notifications), FtmsError> {
|
||||
if !peripheral.is_connected().await.unwrap_or(false) {
|
||||
peripheral.connect().await?;
|
||||
}
|
||||
@@ -293,6 +404,12 @@ async fn setup_session(peripheral: Peripheral) -> Result<(Session, Notifications
|
||||
.as_ref()
|
||||
.map(|d| d.address.clone())
|
||||
.unwrap_or_default(),
|
||||
// A peripheral that is already connected may no longer carry its
|
||||
// advertisement, so fall back to the pod we went looking for.
|
||||
pod: described
|
||||
.as_ref()
|
||||
.and_then(|d| d.pod_id())
|
||||
.or(asked_for),
|
||||
name: described.and_then(|d| d.name),
|
||||
peripheral,
|
||||
subscribed,
|
||||
@@ -303,7 +420,7 @@ async fn setup_session(peripheral: Peripheral) -> Result<(Session, Notifications
|
||||
|
||||
struct Actor {
|
||||
adapter: Adapter,
|
||||
selector: TrainerSelector,
|
||||
selector: PodSelector,
|
||||
config: ClickConfig,
|
||||
events_tx: broadcast::Sender<ClickEvent>,
|
||||
tracker: ButtonTracker,
|
||||
@@ -357,6 +474,11 @@ impl Actor {
|
||||
loop {
|
||||
if self.config.backoff.exhausted(attempt) {
|
||||
tracing::warn!("click: giving up after {attempt} reconnect attempts");
|
||||
// Say so before going away. Leaving silently strands the
|
||||
// supervisor holding a handle whose actor is gone, with
|
||||
// nothing to tell the rider why the buttons stopped working
|
||||
// (FR-1.11).
|
||||
let _ = self.events_tx.send(ClickEvent::GaveUp { attempts: attempt });
|
||||
return;
|
||||
}
|
||||
let delay = self.config.backoff.delay(attempt);
|
||||
@@ -399,6 +521,7 @@ impl Actor {
|
||||
let _ = self.events_tx.send(ClickEvent::Connected {
|
||||
address: session.address.clone(),
|
||||
name: session.name.clone(),
|
||||
pod: session.pod,
|
||||
});
|
||||
current = session;
|
||||
notifications = stream;
|
||||
@@ -492,6 +615,43 @@ mod tests {
|
||||
assert_eq!(c.backoff.max_attempts, None);
|
||||
}
|
||||
|
||||
/// The bug this whole selector exists to kill: both pods of a pair
|
||||
/// advertise the identical local name, so "the Click" is not a thing you
|
||||
/// can ask a scan for. Which one you got was a race.
|
||||
#[test]
|
||||
fn a_pod_is_picked_by_its_type_byte_not_by_a_name_they_both_share() {
|
||||
let minus = Some(zwift::DeviceKind::from_type_byte(0x0B));
|
||||
let plus = Some(zwift::DeviceKind::from_type_byte(0x0A));
|
||||
|
||||
let want_minus = PodSelector::Pod(PodId::Minus);
|
||||
assert!(want_minus.matches_parts("f4:c4:59:03:a1:8e", minus));
|
||||
assert!(!want_minus.matches_parts("c0:4a:0e:f9:a8:78", plus));
|
||||
|
||||
let want_plus = PodSelector::Pod(PodId::Plus);
|
||||
assert!(want_plus.matches_parts("c0:4a:0e:f9:a8:78", plus));
|
||||
assert!(!want_plus.matches_parts("f4:c4:59:03:a1:8e", minus));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_address_identifies_a_pod_that_has_stopped_advertising() {
|
||||
// Reconnect runs against a pod that may no longer be broadcasting its
|
||||
// manufacturer data. Insisting on the type byte here would mean never
|
||||
// finding it again.
|
||||
let s = PodSelector::Address("F4:C4:59:03:A1:8E".into());
|
||||
assert!(s.matches_parts("f4:c4:59:03:a1:8e", None));
|
||||
assert!(!s.matches_parts("c0:4a:0e:f9:a8:78", None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_takes_a_click_but_not_a_trainer_speaking_the_zwift_protocol() {
|
||||
// The D100 advertises Zwift's custom service with no manufacturer data.
|
||||
// Connecting to it as a controller would wedge the trainer link.
|
||||
let s = PodSelector::Any;
|
||||
assert!(s.matches_parts("aa", Some(zwift::DeviceKind::from_type_byte(0x0A))));
|
||||
assert!(!s.matches_parts("aa", None));
|
||||
assert!(!s.matches_parts("aa", Some(zwift::DeviceKind::Unknown(0x77))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn events_compare_by_value() {
|
||||
assert_eq!(
|
||||
|
||||
+150
-1
@@ -37,6 +37,7 @@ use crate::error::FtmsError;
|
||||
use crate::indoor_bike_data::{self, hex};
|
||||
use crate::scan::{self, TrainerSelector};
|
||||
use crate::uuids;
|
||||
use crate::zwift;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration
|
||||
@@ -316,6 +317,7 @@ impl FtmsClient {
|
||||
halted: false,
|
||||
ride_start: Instant::now(),
|
||||
last_health_check: Instant::now(),
|
||||
zwift_cadence: None,
|
||||
};
|
||||
|
||||
tokio::spawn(actor.run(cmd_rx, Some(connected.notifications)));
|
||||
@@ -581,9 +583,24 @@ struct Actor {
|
||||
halted: bool,
|
||||
ride_start: Instant,
|
||||
last_health_check: Instant,
|
||||
/// Latest cadence from the trainer's Zwift channel, and when it arrived.
|
||||
///
|
||||
/// This trainer declares no cadence in its Fitness Machine Feature bits and
|
||||
/// sends none in Indoor Bike Data, so FTMS alone cannot supply it — see
|
||||
/// [`crate::zwift::RidingData`]. Riding data arrives at about 1 Hz against
|
||||
/// Indoor Bike Data's ~5 Hz, so it is held here and stamped onto the faster
|
||||
/// stream rather than published as telemetry of its own.
|
||||
zwift_cadence: Option<(f32, Instant)>,
|
||||
}
|
||||
|
||||
const HEALTH_CHECK_INTERVAL: Duration = Duration::from_secs(3);
|
||||
/// How long a cadence from the Zwift channel stays believable.
|
||||
///
|
||||
/// Generous against its ~1 Hz rate, but finite: a rider who stops pedalling
|
||||
/// must read as stopped, and a channel that dies must not leave the last
|
||||
/// cadence under the ride forever — with speed taken from cadence × gear, a
|
||||
/// stale value is a ride that keeps rolling on its own.
|
||||
const ZWIFT_CADENCE_TTL: Duration = Duration::from_secs(4);
|
||||
|
||||
impl Actor {
|
||||
async fn run(
|
||||
@@ -658,7 +675,41 @@ impl Actor {
|
||||
|
||||
// -- inbound ----------------------------------------------------------
|
||||
|
||||
/// The Zwift-channel cadence, if one arrived recently enough to trust.
|
||||
fn fresh_zwift_cadence(&self) -> Option<f32> {
|
||||
self.zwift_cadence
|
||||
.filter(|(_, at)| at.elapsed() < ZWIFT_CADENCE_TTL)
|
||||
.map(|(rpm, _)| rpm)
|
||||
}
|
||||
|
||||
fn handle_notification(&mut self, n: ValueNotification) {
|
||||
if zwift::is_zwift_uuid(n.uuid) {
|
||||
// Any characteristic in the trainer's Zwift service, not just the
|
||||
// one the Click uses — which one a trainer talks on is undocumented.
|
||||
// Only riding-data frames (type 0x03) mean anything; the channel
|
||||
// carries other traffic and `decode_riding_data` rejects all of it.
|
||||
match zwift::decode_riding_data(&n.value) {
|
||||
Some(data) => {
|
||||
tracing::debug!(
|
||||
uuid = %n.uuid,
|
||||
raw = %hex(&n.value),
|
||||
cadence_rpm = data.cadence_rpm(),
|
||||
power_w = data.power_w,
|
||||
"zwift riding data"
|
||||
);
|
||||
self.zwift_cadence = Some((data.cadence_rpm(), Instant::now()));
|
||||
}
|
||||
// Logged, not swallowed. If cadence never arrives, the question
|
||||
// is whether the channel is silent or merely speaking a dialect
|
||||
// we do not decode, and only these lines can tell the two apart.
|
||||
None => tracing::debug!(
|
||||
uuid = %n.uuid,
|
||||
raw = %hex(&n.value),
|
||||
"zwift frame that is not riding data"
|
||||
),
|
||||
}
|
||||
return;
|
||||
}
|
||||
if n.uuid == uuids::INDOOR_BIKE_DATA {
|
||||
tracing::trace!(raw = %hex(&n.value), "0x2AD2 indoor bike data");
|
||||
match indoor_bike_data::decode(&n.value) {
|
||||
@@ -670,7 +721,14 @@ impl Actor {
|
||||
"indoor bike data had trailing bytes we do not understand"
|
||||
);
|
||||
}
|
||||
let telemetry = data.to_telemetry(self.elapsed_ms());
|
||||
let mut telemetry = data.to_telemetry(self.elapsed_ms());
|
||||
// FTMS carries no cadence on this trainer; the Zwift
|
||||
// channel does. Fill it in, but never overwrite a cadence
|
||||
// FTMS did report — a trainer that declares one is the
|
||||
// better authority on it.
|
||||
if telemetry.cadence_rpm.is_none() {
|
||||
telemetry.cadence_rpm = self.fresh_zwift_cadence();
|
||||
}
|
||||
let _ = self.telemetry_tx.send(telemetry);
|
||||
let _ = self.events_tx.send(FtmsEvent::Telemetry(telemetry));
|
||||
}
|
||||
@@ -1344,6 +1402,15 @@ async fn setup_session(
|
||||
}
|
||||
}
|
||||
|
||||
// The trainer's Zwift channel, which is where its cadence lives — FTMS on
|
||||
// this hardware reports none at all (see `zwift::RidingData`), and with the
|
||||
// speed taken from cadence × gear, no cadence means no ride.
|
||||
//
|
||||
// Optional in every sense: a trainer without the service simply rides
|
||||
// without cadence, and nothing here may cost us the FTMS session that is
|
||||
// already working. Hence the warnings rather than `?`.
|
||||
subscribe_zwift_channel(&peripheral, &chars).await;
|
||||
|
||||
let mut notif_rx = notif_rx;
|
||||
|
||||
// FR-2.1: control first, everything else after.
|
||||
@@ -1384,6 +1451,88 @@ async fn setup_session(
|
||||
))
|
||||
}
|
||||
|
||||
/// Subscribe to the trainer's Zwift channel and complete the `RideOn`
|
||||
/// handshake, so it starts reporting cadence.
|
||||
///
|
||||
/// The handshake is the part that is easy to miss. Subscribing alone is not
|
||||
/// enough: like the Click, the trainer's Zwift service says nothing until it
|
||||
/// has been greeted, so a client that only subscribes sits there receiving
|
||||
/// silence and concludes the device has no cadence to give. Every symptom of
|
||||
/// that is indistinguishable from a trainer that genuinely has none.
|
||||
///
|
||||
/// Entirely best-effort. A trainer without the service, or one that refuses the
|
||||
/// write, rides without cadence; none of it may disturb the FTMS session, which
|
||||
/// is the part that actually controls the resistance.
|
||||
async fn subscribe_zwift_channel(
|
||||
peripheral: &Peripheral,
|
||||
chars: &std::collections::BTreeSet<Characteristic>,
|
||||
) {
|
||||
let zwift_char = |uuid: Uuid| chars.iter().find(|c| c.uuid == uuid).cloned();
|
||||
|
||||
let zwift_chars: Vec<&Characteristic> = chars
|
||||
.iter()
|
||||
.filter(|c| zwift::is_zwift_uuid(c.uuid))
|
||||
.collect();
|
||||
if zwift_chars.is_empty() {
|
||||
tracing::info!("trainer exposes no Zwift channel; it will report no cadence");
|
||||
return;
|
||||
}
|
||||
// Listed because which characteristic a *trainer* publishes riding data on
|
||||
// is not documented — the Click's is `ASYNC`, and betting on that being
|
||||
// universal is what this list exists to disprove or confirm.
|
||||
for c in &zwift_chars {
|
||||
tracing::debug!(uuid = %c.uuid, properties = ?c.properties, "zwift characteristic");
|
||||
}
|
||||
|
||||
// Subscribe to every notifying characteristic in the service, exactly as
|
||||
// the Click path does, rather than guessing which one carries cadence. A
|
||||
// subscription we did not need costs nothing; the one we failed to make
|
||||
// costs the entire ride, because speed comes from cadence.
|
||||
let mut subscribed = 0;
|
||||
for c in &zwift_chars {
|
||||
if !c
|
||||
.properties
|
||||
.intersects(btleplug::api::CharPropFlags::NOTIFY | btleplug::api::CharPropFlags::INDICATE)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
match peripheral.subscribe(c).await {
|
||||
Ok(()) => {
|
||||
subscribed += 1;
|
||||
tracing::debug!(uuid = %c.uuid, "subscribed to a Zwift characteristic");
|
||||
}
|
||||
Err(e) => tracing::warn!(uuid = %c.uuid, error = %e, "Zwift subscribe failed"),
|
||||
}
|
||||
}
|
||||
if subscribed == 0 {
|
||||
tracing::warn!("the trainer's Zwift service notifies on nothing; no cadence");
|
||||
return;
|
||||
}
|
||||
|
||||
// Subscribed above, before writing, so the reply cannot outrun us.
|
||||
let Some(sync_rx) = zwift_char(zwift::SYNC_RX) else {
|
||||
// Subscribed but ungreetable. Worth saying: if cadence never arrives,
|
||||
// this line is the reason.
|
||||
tracing::warn!("trainer has a Zwift channel but no sync RX to greet it on");
|
||||
return;
|
||||
};
|
||||
let write_type = if sync_rx
|
||||
.properties
|
||||
.contains(btleplug::api::CharPropFlags::WRITE_WITHOUT_RESPONSE)
|
||||
{
|
||||
WriteType::WithoutResponse
|
||||
} else {
|
||||
WriteType::WithResponse
|
||||
};
|
||||
match peripheral
|
||||
.write(&sync_rx, &zwift::handshake(&zwift::REQUEST_START), write_type)
|
||||
.await
|
||||
{
|
||||
Ok(()) => tracing::info!("greeted the trainer's Zwift channel; expecting cadence"),
|
||||
Err(e) => tracing::warn!(error = %e, "Zwift handshake write failed; expect no cadence"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_capabilities(
|
||||
peripheral: &Peripheral,
|
||||
find: &impl Fn(Uuid) -> Option<Characteristic>,
|
||||
|
||||
@@ -57,7 +57,7 @@ pub mod scan;
|
||||
pub mod uuids;
|
||||
pub mod zwift;
|
||||
|
||||
pub use click::{ClickClient, ClickConfig, ClickEvent};
|
||||
pub use click::{ClickClient, ClickConfig, ClickEvent, PodSelector};
|
||||
pub use capabilities::{
|
||||
FitnessMachineFeature, InclinationRange, PowerRange, ResistanceLevelRange, TrainerCapabilities,
|
||||
UnsupportedTarget,
|
||||
@@ -74,5 +74,5 @@ pub use scan::{
|
||||
pub use uuids::FITNESS_MACHINE_SERVICE;
|
||||
pub use zwift::{
|
||||
Button, ButtonBitmask, ClickButtons, DeviceKind as ZwiftDeviceKind,
|
||||
MessageType as ZwiftMessageType,
|
||||
MessageType as ZwiftMessageType, PodId,
|
||||
};
|
||||
|
||||
+40
-4
@@ -59,6 +59,19 @@ impl DiscoveredDevice {
|
||||
.and_then(|d| zwift::DeviceKind::from_manufacturer_data(d))
|
||||
}
|
||||
|
||||
/// True when this is a controller pod the app can drive — a Click, not a
|
||||
/// trainer that merely speaks the Zwift protocol. The D100 advertises the
|
||||
/// custom service with no manufacturer data, which is exactly the case this
|
||||
/// separates out.
|
||||
pub fn is_click_pod(&self) -> bool {
|
||||
self.zwift_kind().is_some_and(|k| k.is_click())
|
||||
}
|
||||
|
||||
/// Which pod of a pair this is, where the advertisement says so (FR-1.4).
|
||||
pub fn pod_id(&self) -> Option<zwift::PodId> {
|
||||
self.zwift_kind().and_then(|k| k.pod_id())
|
||||
}
|
||||
|
||||
/// Best-effort human label.
|
||||
pub fn label(&self) -> String {
|
||||
match &self.name {
|
||||
@@ -224,7 +237,29 @@ pub async fn find_peripheral(
|
||||
selector: &TrainerSelector,
|
||||
timeout: Duration,
|
||||
) -> Result<Peripheral, FtmsError> {
|
||||
let kind = selector.scan_kind();
|
||||
find_matching(
|
||||
adapter,
|
||||
selector.scan_kind(),
|
||||
timeout,
|
||||
&selector.describe(),
|
||||
|d| selector.matches(d),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Scan until a peripheral satisfying `matches` appears, or `timeout` elapses.
|
||||
///
|
||||
/// The predicate form exists because a controller is not selected the way a
|
||||
/// trainer is: a Click pod is picked out by the type byte in its manufacturer
|
||||
/// data (§2.3.1), which no `TrainerSelector` variant can express. `what`
|
||||
/// describes the search well enough to read in an error message.
|
||||
pub async fn find_matching(
|
||||
adapter: &Adapter,
|
||||
kind: ScanKind,
|
||||
timeout: Duration,
|
||||
what: &str,
|
||||
matches: impl Fn(&DiscoveredDevice) -> bool,
|
||||
) -> Result<Peripheral, FtmsError> {
|
||||
adapter.start_scan(kind.filter()).await?;
|
||||
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
@@ -234,12 +269,13 @@ pub async fn find_peripheral(
|
||||
'search: loop {
|
||||
for p in adapter.peripherals().await?.into_iter() {
|
||||
if let Some(d) = describe(&p).await {
|
||||
if selector.matches(&d) {
|
||||
if matches(&d) {
|
||||
tracing::info!(
|
||||
address = %d.address,
|
||||
name = d.label(),
|
||||
rssi = ?d.rssi,
|
||||
"matched trainer"
|
||||
%what,
|
||||
"matched peripheral"
|
||||
);
|
||||
found = Some(p);
|
||||
break 'search;
|
||||
@@ -256,7 +292,7 @@ pub async fn find_peripheral(
|
||||
tracing::debug!(error = %e, "stop_scan failed");
|
||||
}
|
||||
|
||||
found.ok_or_else(|| FtmsError::NotFound(selector.describe()))
|
||||
found.ok_or_else(|| FtmsError::NotFound(what.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+132
-10
@@ -103,17 +103,73 @@ pub fn is_zwift_uuid(uuid: Uuid) -> bool {
|
||||
// Device discrimination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Which of a pair of pods this is.
|
||||
///
|
||||
/// A Click v2 is **two independent peripherals** (§2.3.1, FR-1.4), each with
|
||||
/// its own address, battery and link — so the app has to track two of
|
||||
/// everything, and the rider has to be told which of the two is missing.
|
||||
///
|
||||
/// They are named for the **shift paddle** each one carries rather than for the
|
||||
/// side of the bar they clamp to. Left and right would be a guess: nothing in
|
||||
/// the advertisement says which end of the handlebar a pod is on, and a rider
|
||||
/// who mounts them the other way round makes the label a lie. The paddle is
|
||||
/// printed on the pod, so `+` and `−` are checkable by eye and by pressing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum PodId {
|
||||
/// The pod carrying the `−` paddle: shift down, and the D-pad.
|
||||
Minus,
|
||||
/// The pod carrying the `+` paddle: shift up, and the lettered face buttons.
|
||||
Plus,
|
||||
}
|
||||
|
||||
impl PodId {
|
||||
pub const BOTH: [PodId; 2] = [PodId::Minus, PodId::Plus];
|
||||
|
||||
pub fn other(self) -> Self {
|
||||
match self {
|
||||
PodId::Minus => PodId::Plus,
|
||||
PodId::Plus => PodId::Minus,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lowercase, stable — used as a key by the app and the webview.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
PodId::Minus => "minus",
|
||||
PodId::Plus => "plus",
|
||||
}
|
||||
}
|
||||
|
||||
/// How the pod is written on screen.
|
||||
pub fn symbol(self) -> &'static str {
|
||||
match self {
|
||||
PodId::Minus => "−",
|
||||
PodId::Plus => "+",
|
||||
}
|
||||
}
|
||||
|
||||
/// The paddle whose press proves a pod is the one we filed it under.
|
||||
pub fn paddle(self) -> Button {
|
||||
match self {
|
||||
PodId::Minus => Button::Minus,
|
||||
PodId::Plus => Button::Plus,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What kind of Zwift peripheral is advertising, from the first byte of its
|
||||
/// manufacturer data (§2.3.1).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DeviceKind {
|
||||
/// `0x09` — Click v1. Unencrypted; the easy case.
|
||||
ClickV1,
|
||||
/// `0x0A` / `0x0B` — Click v2. The target hardware (§2.3).
|
||||
ClickV2,
|
||||
/// `0x03` — Play, left pod. *Unverified.*
|
||||
/// `0x0B` / `0x0A` — Click v2. The target hardware (§2.3). **One byte per
|
||||
/// pod, not a version marker** — confirmed on our own pair, where
|
||||
/// `f4:c4:59:03:a1:8e` advertises `0x0B` and `c0:4a:0e:f9:a8:78` `0x0A`.
|
||||
ClickV2(PodId),
|
||||
/// `0x03` — Play, left-hand pod (`−` paddle). *Unverified.*
|
||||
PlayLeft,
|
||||
/// `0x02` — Play, right pod. *Unverified.*
|
||||
/// `0x02` — Play, right-hand pod (`+` paddle). *Unverified.*
|
||||
PlayRight,
|
||||
/// A Zwift device we do not have a byte for. Carries the raw value so the
|
||||
/// probe can report it rather than swallow it.
|
||||
@@ -122,10 +178,21 @@ pub enum DeviceKind {
|
||||
|
||||
impl DeviceKind {
|
||||
/// Classify from the device type byte.
|
||||
///
|
||||
/// Which v2 byte is which pod is a **starting guess**: §2.3.1 confirms one
|
||||
/// byte per pod, but no capture names them. It is read by analogy with
|
||||
/// Play, whose `+` pod is the lower byte (`0x02`) and `−` pod the higher
|
||||
/// (`0x03`), and it agrees with the one thing we did observe — the D-pad
|
||||
/// frames, which live beside the `−` paddle, came from the `0x0B` pod.
|
||||
///
|
||||
/// The guess does not have to be right. The app confirms each pod the first
|
||||
/// time a paddle is pressed on it, and swaps the pair if the pods answer to
|
||||
/// the other name.
|
||||
pub fn from_type_byte(b: u8) -> Self {
|
||||
match b {
|
||||
0x09 => DeviceKind::ClickV1,
|
||||
0x0A | 0x0B => DeviceKind::ClickV2,
|
||||
0x0B => DeviceKind::ClickV2(PodId::Minus),
|
||||
0x0A => DeviceKind::ClickV2(PodId::Plus),
|
||||
0x03 => DeviceKind::PlayLeft,
|
||||
0x02 => DeviceKind::PlayRight,
|
||||
other => DeviceKind::Unknown(other),
|
||||
@@ -140,13 +207,28 @@ impl DeviceKind {
|
||||
|
||||
/// True for a Click of either generation — the devices this app drives.
|
||||
pub fn is_click(self) -> bool {
|
||||
matches!(self, DeviceKind::ClickV1 | DeviceKind::ClickV2)
|
||||
matches!(self, DeviceKind::ClickV1 | DeviceKind::ClickV2(_))
|
||||
}
|
||||
|
||||
/// Which pod of a pair this is, where the advertisement says so.
|
||||
///
|
||||
/// `None` for a v1 Click, which is a single unit, and for anything we
|
||||
/// cannot place — a pod with no id is still connectable, it just cannot be
|
||||
/// filed under one of the two slots on the connection screen.
|
||||
pub fn pod_id(self) -> Option<PodId> {
|
||||
match self {
|
||||
DeviceKind::ClickV2(side) => Some(side),
|
||||
DeviceKind::PlayLeft => Some(PodId::Minus),
|
||||
DeviceKind::PlayRight => Some(PodId::Plus),
|
||||
DeviceKind::ClickV1 | DeviceKind::Unknown(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn describe(self) -> String {
|
||||
match self {
|
||||
DeviceKind::ClickV1 => "Zwift Click v1".into(),
|
||||
DeviceKind::ClickV2 => "Zwift Click v2".into(),
|
||||
DeviceKind::ClickV2(PodId::Minus) => "Zwift Click v2 (− pod)".into(),
|
||||
DeviceKind::ClickV2(PodId::Plus) => "Zwift Click v2 (+ pod)".into(),
|
||||
DeviceKind::PlayLeft => "Zwift Play (left)".into(),
|
||||
DeviceKind::PlayRight => "Zwift Play (right)".into(),
|
||||
DeviceKind::Unknown(b) => format!("unrecognised Zwift device (type byte 0x{b:02x})"),
|
||||
@@ -620,8 +702,17 @@ mod tests {
|
||||
#[test]
|
||||
fn device_type_bytes_classify() {
|
||||
assert_eq!(DeviceKind::from_type_byte(0x09), DeviceKind::ClickV1);
|
||||
assert_eq!(DeviceKind::from_type_byte(0x0A), DeviceKind::ClickV2);
|
||||
assert_eq!(DeviceKind::from_type_byte(0x0B), DeviceKind::ClickV2);
|
||||
// §2.3.1: one byte per pod, not a version marker. The two v2 bytes must
|
||||
// therefore land on *different* pods — a mapping that collapsed them
|
||||
// would put both pods in one slot and lose the other entirely.
|
||||
assert_eq!(
|
||||
DeviceKind::from_type_byte(0x0B),
|
||||
DeviceKind::ClickV2(PodId::Minus)
|
||||
);
|
||||
assert_eq!(
|
||||
DeviceKind::from_type_byte(0x0A),
|
||||
DeviceKind::ClickV2(PodId::Plus)
|
||||
);
|
||||
assert_eq!(DeviceKind::from_type_byte(0x02), DeviceKind::PlayRight);
|
||||
assert_eq!(DeviceKind::from_type_byte(0xFE), DeviceKind::Unknown(0xFE));
|
||||
assert!(DeviceKind::from_type_byte(0x0B).is_click());
|
||||
@@ -632,11 +723,42 @@ mod tests {
|
||||
fn manufacturer_data_needs_at_least_one_byte() {
|
||||
assert_eq!(
|
||||
DeviceKind::from_manufacturer_data(&[0x0A, 0x00]),
|
||||
Some(DeviceKind::ClickV2)
|
||||
Some(DeviceKind::ClickV2(PodId::Plus))
|
||||
);
|
||||
assert_eq!(DeviceKind::from_manufacturer_data(&[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pair_of_pods_covers_both_ids_and_nothing_else_claims_one() {
|
||||
let minus = DeviceKind::from_type_byte(0x0B).pod_id();
|
||||
let plus = DeviceKind::from_type_byte(0x0A).pod_id();
|
||||
assert_eq!(minus, Some(PodId::Minus));
|
||||
assert_eq!(plus, Some(PodId::Plus));
|
||||
assert_eq!(minus.map(PodId::other), plus);
|
||||
|
||||
// A v1 Click is a single unit: giving it an id would fill a slot the
|
||||
// rider has no pod for.
|
||||
assert_eq!(DeviceKind::ClickV1.pod_id(), None);
|
||||
assert_eq!(DeviceKind::Unknown(0x77).pod_id(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pod_names_are_the_keys_the_webview_switches_on() {
|
||||
assert_eq!(PodId::Minus.as_str(), "minus");
|
||||
assert_eq!(PodId::Plus.as_str(), "plus");
|
||||
assert_eq!(PodId::BOTH, [PodId::Minus, PodId::Plus]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pod_is_named_for_the_paddle_that_proves_which_one_it_is() {
|
||||
// The whole point of naming them `+` and `−` rather than left and
|
||||
// right: pressing the paddle settles the question, and no guess about
|
||||
// how the pods are mounted can make the label wrong.
|
||||
assert_eq!(PodId::Plus.paddle(), Button::Plus);
|
||||
assert_eq!(PodId::Minus.paddle(), Button::Minus);
|
||||
assert_eq!(PodId::Plus.symbol(), "+");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handshake_is_ride_on_plus_suffix() {
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user