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,397 @@
|
||||
//! The controller supervisor: the app's single owner of a [`ClickClient`].
|
||||
//!
|
||||
//! The same shape as [`crate::trainer`], and for the same reason — the Tauri
|
||||
//! commands hold a `Mutex` and may never `.await` a radio, so all BLE work
|
||||
//! happens in one background task that they reach over a channel.
|
||||
//!
|
||||
//! ```text
|
||||
//! commands ──connect/disconnect──► [supervisor task] ──► ClickClient
|
||||
//! webview ◄──controller://input──── button edges ◄─────────┘
|
||||
//! ```
|
||||
//!
|
||||
//! The difference from the trainer is that a controller has **no safety
|
||||
//! story**: it never commands load, so there is no SAF-2 sequence to run and
|
||||
//! nothing to reset on exit. It still has to be *disconnected* on the way out
|
||||
//! though (SAF-9) — a link the process merely abandons can leave the pod held
|
||||
//! by BlueZ and unreachable on the next launch — and that disconnect has to be
|
||||
//! waited for, which is what [`ControllerHandle::shutdown_blocking`] is.
|
||||
//!
|
||||
//! Button *edges* arrive already de-duplicated by `ButtonTracker` in the BLE
|
||||
//! layer — the pod repeats a held button at ~10 Hz, and acting on repeats would
|
||||
//! shift ten gears per second.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::{sync_channel, SyncSender};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use bikecontrol_ble::click::{ClickClient, ClickConfig, ClickEvent};
|
||||
use bikecontrol_ble::zwift::Button;
|
||||
use bikecontrol_ble::TrainerSelector;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
|
||||
/// How long a controller may stay silent before we call it stale. The pod sends
|
||||
/// a battery heartbeat every ~5 s even when idle, so 20 s is four missed beats.
|
||||
const STALE_AFTER: Duration = Duration::from_secs(20);
|
||||
/// Upper bound on closing the controller link at exit. Shorter than the
|
||||
/// trainer's: there is no reset sequence here, only an unsubscribe and a
|
||||
/// disconnect, and this budget is spent on the same window close (NFR-9).
|
||||
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
|
||||
/// What the UI needs to know about the controller link.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ControllerStatus {
|
||||
pub connected: bool,
|
||||
pub address: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub battery_percent: Option<u8>,
|
||||
/// Rendered verbatim (FR-9.2).
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// A button edge, on its way to the webview.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ControllerInput {
|
||||
/// Stable lowercase name: `left`, `up`, `right`, `down`, `a`, `b`, `y`,
|
||||
/// `z`, `minus`, `plus`. The webview switches on this, so it must not drift
|
||||
/// from [`button_name`].
|
||||
pub button: &'static str,
|
||||
pub pressed: bool,
|
||||
}
|
||||
|
||||
/// The webview-facing name for a button. Deliberately not `Button::label`,
|
||||
/// which returns `-` and `+` — awkward to switch on in TypeScript.
|
||||
pub fn button_name(button: Button) -> &'static str {
|
||||
match button {
|
||||
Button::Left => "left",
|
||||
Button::Up => "up",
|
||||
Button::Right => "right",
|
||||
Button::Down => "down",
|
||||
Button::A => "a",
|
||||
Button::B => "b",
|
||||
Button::Y => "y",
|
||||
Button::Z => "z",
|
||||
Button::Minus => "minus",
|
||||
Button::Plus => "plus",
|
||||
}
|
||||
}
|
||||
|
||||
enum Cmd {
|
||||
Connect(TrainerSelector),
|
||||
Disconnect,
|
||||
/// Close the link and stop, answering only once it is actually closed.
|
||||
Shutdown { reply: SyncSender<()> },
|
||||
}
|
||||
|
||||
/// A command that arrived while a connect was in flight and means "stop"
|
||||
/// (FR-1.10).
|
||||
enum Abort {
|
||||
Disconnect,
|
||||
Shutdown(SyncSender<()>),
|
||||
/// Every handle dropped.
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// Cheap, cloneable handle to the supervisor.
|
||||
#[derive(Clone)]
|
||||
pub struct ControllerHandle {
|
||||
cmd_tx: mpsc::Sender<Cmd>,
|
||||
status_rx: watch::Receiver<ControllerStatus>,
|
||||
/// Button edges, for whoever forwards them to the webview.
|
||||
input_tx: Arc<tokio::sync::broadcast::Sender<ControllerInput>>,
|
||||
/// Shared, so every clone of the handle sees that the link is already shut.
|
||||
shut_down: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl ControllerHandle {
|
||||
/// Start the supervisor task. Needs a Tokio runtime, which
|
||||
/// `tauri::async_runtime` provides before the app is built.
|
||||
pub fn spawn() -> Self {
|
||||
let (cmd_tx, cmd_rx) = mpsc::channel(16);
|
||||
let (status_tx, status_rx) = watch::channel(ControllerStatus::default());
|
||||
let (input_tx, _) = tokio::sync::broadcast::channel(64);
|
||||
let input_tx = Arc::new(input_tx);
|
||||
|
||||
let handle = Self {
|
||||
cmd_tx,
|
||||
status_rx,
|
||||
input_tx: input_tx.clone(),
|
||||
shut_down: Arc::new(AtomicBool::new(false)),
|
||||
};
|
||||
tauri::async_runtime::spawn(run(cmd_rx, status_tx, input_tx));
|
||||
handle
|
||||
}
|
||||
|
||||
pub fn status(&self) -> ControllerStatus {
|
||||
self.status_rx.borrow().clone()
|
||||
}
|
||||
|
||||
/// A watch receiver, for a loop that wants to react to link changes rather
|
||||
/// than poll them.
|
||||
pub fn status_watch(&self) -> watch::Receiver<ControllerStatus> {
|
||||
self.status_rx.clone()
|
||||
}
|
||||
|
||||
/// Subscribe to button edges.
|
||||
pub fn inputs(&self) -> tokio::sync::broadcast::Receiver<ControllerInput> {
|
||||
self.input_tx.subscribe()
|
||||
}
|
||||
|
||||
pub fn connect(&self, selector: TrainerSelector) {
|
||||
let _ = self.cmd_tx.try_send(Cmd::Connect(selector));
|
||||
}
|
||||
|
||||
pub fn disconnect(&self) {
|
||||
let _ = self.cmd_tx.try_send(Cmd::Disconnect);
|
||||
}
|
||||
|
||||
/// Close the controller link at app exit, waiting for it to be closed
|
||||
/// (SAF-9). Blocks the calling (non-async) thread until it is done or
|
||||
/// [`SHUTDOWN_TIMEOUT`] elapses.
|
||||
///
|
||||
/// Idempotent: Tauri delivers `ExitRequested`, `Exit` and window `Destroyed`
|
||||
/// for a single quit, and the repeats must be silent no-ops.
|
||||
pub fn shutdown_blocking(&self) {
|
||||
if self.shut_down.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let deadline = Instant::now() + SHUTDOWN_TIMEOUT;
|
||||
let (reply, done) = sync_channel(1);
|
||||
|
||||
let mut cmd = Cmd::Shutdown { reply };
|
||||
loop {
|
||||
match self.cmd_tx.try_send(cmd) {
|
||||
Ok(()) => break,
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => return,
|
||||
Err(mpsc::error::TrySendError::Full(returned)) => {
|
||||
if Instant::now() >= deadline {
|
||||
tracing::warn!("controller supervisor unreachable; link left to the OS");
|
||||
return;
|
||||
}
|
||||
cmd = returned;
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
match done.recv_timeout(remaining) {
|
||||
Ok(()) => tracing::info!("controller disconnected"),
|
||||
Err(e) => tracing::warn!(error = %e, "controller did not disconnect in time"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(
|
||||
mut cmd_rx: mpsc::Receiver<Cmd>,
|
||||
status_tx: watch::Sender<ControllerStatus>,
|
||||
input_tx: Arc<tokio::sync::broadcast::Sender<ControllerInput>>,
|
||||
) {
|
||||
let mut client: Option<ClickClient> = None;
|
||||
let mut events: Option<tokio::sync::broadcast::Receiver<ClickEvent>> = None;
|
||||
let mut last_seen = tokio::time::Instant::now();
|
||||
|
||||
let mut housekeeping = tokio::time::interval(Duration::from_secs(5));
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
cmd = cmd_rx.recv() => match cmd {
|
||||
Some(Cmd::Connect(selector)) => {
|
||||
if let Some(existing) = client.take() {
|
||||
existing.shutdown().await;
|
||||
}
|
||||
events = None;
|
||||
status_tx.send_modify(|s| {
|
||||
*s = ControllerStatus { error: None, ..Default::default() }
|
||||
});
|
||||
|
||||
// A pod only advertises after a button press (A-4), so this
|
||||
// routinely runs the full scan timeout. Awaiting it without
|
||||
// a way out would make a quit — or even a Disconnect click —
|
||||
// wait twenty seconds behind it (FR-1.10).
|
||||
let mut abort: Option<Abort> = None;
|
||||
let outcome = {
|
||||
let cancel = async {
|
||||
abort = Some(abort_signal(&mut cmd_rx).await);
|
||||
};
|
||||
ClickClient::connect_cancellable(
|
||||
selector,
|
||||
ClickConfig::default(),
|
||||
cancel,
|
||||
)
|
||||
.await
|
||||
};
|
||||
|
||||
match outcome {
|
||||
Ok(Some(c)) => {
|
||||
events = Some(c.events());
|
||||
client = Some(c);
|
||||
last_seen = tokio::time::Instant::now();
|
||||
}
|
||||
Ok(None) => tracing::info!("controller: connect abandoned"),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "controller: connect failed");
|
||||
status_tx.send_modify(|s| s.error = Some(e.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// Whatever interrupted the connect still has to be honoured.
|
||||
match abort {
|
||||
None => {}
|
||||
Some(Abort::Disconnect) => {
|
||||
if let Some(c) = client.take() {
|
||||
c.shutdown().await;
|
||||
}
|
||||
events = None;
|
||||
status_tx.send_modify(|s| *s = ControllerStatus::default());
|
||||
}
|
||||
Some(Abort::Shutdown(reply)) => {
|
||||
if let Some(c) = client.take() {
|
||||
c.shutdown().await;
|
||||
}
|
||||
status_tx.send_modify(|s| *s = ControllerStatus::default());
|
||||
let _ = reply.send(());
|
||||
return;
|
||||
}
|
||||
Some(Abort::Closed) => {
|
||||
if let Some(c) = client.take() {
|
||||
c.shutdown().await;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Cmd::Disconnect) => {
|
||||
if let Some(c) = client.take() {
|
||||
c.shutdown().await;
|
||||
}
|
||||
events = None;
|
||||
status_tx.send_modify(|s| *s = ControllerStatus::default());
|
||||
}
|
||||
Some(Cmd::Shutdown { reply }) => {
|
||||
if let Some(c) = client.take() {
|
||||
// Waits for the pod's link to actually close (SAF-9).
|
||||
c.shutdown().await;
|
||||
}
|
||||
status_tx.send_modify(|s| *s = ControllerStatus::default());
|
||||
let _ = reply.send(());
|
||||
return;
|
||||
}
|
||||
None => {
|
||||
if let Some(c) = client.take() {
|
||||
c.shutdown().await;
|
||||
}
|
||||
return;
|
||||
}
|
||||
},
|
||||
|
||||
// Only polled while a client exists; `recv` on a `None` receiver
|
||||
// would busy-loop, so the branch is disabled instead.
|
||||
event = async { events.as_mut().unwrap().recv().await }, if events.is_some() => {
|
||||
match event {
|
||||
Ok(e) => {
|
||||
last_seen = tokio::time::Instant::now();
|
||||
apply(e, &status_tx, &input_tx);
|
||||
}
|
||||
// Lagged means we fell behind the pod, not that it left.
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
tracing::warn!("controller: dropped {n} event(s)");
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||
events = None;
|
||||
client = None;
|
||||
status_tx.send_modify(|s| *s = ControllerStatus::default());
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_ = housekeeping.tick() => {
|
||||
if client.is_some() && last_seen.elapsed() > STALE_AFTER {
|
||||
// Not an error: the BLE layer is already retrying. Say so
|
||||
// rather than showing a connected pod that is not talking.
|
||||
status_tx.send_modify(|s| s.connected = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Watch for a reason to abandon a connect that is currently running.
|
||||
///
|
||||
/// Connect requests arriving mid-connect are dropped rather than queued: the
|
||||
/// rider clicking twice means "connect", which is what is already happening.
|
||||
async fn abort_signal(cmd_rx: &mut mpsc::Receiver<Cmd>) -> Abort {
|
||||
loop {
|
||||
match cmd_rx.recv().await {
|
||||
None => return Abort::Closed,
|
||||
Some(Cmd::Shutdown { reply }) => return Abort::Shutdown(reply),
|
||||
Some(Cmd::Disconnect) => return Abort::Disconnect,
|
||||
Some(Cmd::Connect(_)) => tracing::debug!("controller: already connecting"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(
|
||||
event: ClickEvent,
|
||||
status_tx: &watch::Sender<ControllerStatus>,
|
||||
input_tx: &tokio::sync::broadcast::Sender<ControllerInput>,
|
||||
) {
|
||||
match event {
|
||||
ClickEvent::Connected { address, name } => status_tx.send_modify(|s| {
|
||||
s.connected = true;
|
||||
s.address = Some(address);
|
||||
s.name = name;
|
||||
s.error = None;
|
||||
}),
|
||||
ClickEvent::Disconnected => status_tx.send_modify(|s| s.connected = false),
|
||||
ClickEvent::Battery { percent } => {
|
||||
status_tx.send_modify(|s| s.battery_percent = Some(percent))
|
||||
}
|
||||
ClickEvent::Button { button, pressed } => {
|
||||
// A send failure only means nobody is listening yet.
|
||||
let _ = input_tx.send(ControllerInput {
|
||||
button: button_name(button),
|
||||
pressed,
|
||||
});
|
||||
}
|
||||
ClickEvent::Unknown { kind, .. } => {
|
||||
tracing::debug!("controller: unhandled frame type 0x{kind:02x}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_button_has_a_distinct_webview_name() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for b in Button::ALL {
|
||||
let name = button_name(b);
|
||||
assert!(seen.insert(name), "{name} is used twice");
|
||||
// The webview switches on these; a stray `+` would need escaping.
|
||||
assert!(
|
||||
name.chars().all(|c| c.is_ascii_lowercase()),
|
||||
"{name} is not a plain lowercase identifier"
|
||||
);
|
||||
}
|
||||
assert_eq!(seen.len(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paddles_are_named_for_typescript_not_for_display() {
|
||||
assert_eq!(button_name(Button::Plus), "plus");
|
||||
assert_eq!(button_name(Button::Minus), "minus");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_status_is_disconnected_and_blameless() {
|
||||
let s = ControllerStatus::default();
|
||||
assert!(!s.connected);
|
||||
assert!(s.error.is_none() && s.battery_percent.is_none());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user