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:
2026-08-05 15:33:28 +02:00
co-authored by Claude Opus 5
parent 3a2a787b7d
commit 57eb5e809b
48 changed files with 57737 additions and 431 deletions
+7
View File
@@ -18,3 +18,10 @@ src-tauri/gen/
# Ride data
*.fit
/rides/
# Arch packaging (makepkg work dirs + built packages)
/packaging/arch/pkg/
/packaging/arch/src/
/packaging/arch/*.pkg.tar.zst
/.cargo-arch/
bikecontrol.log
Generated
+2
View File
@@ -119,6 +119,7 @@ name = "bikecontrol-app"
version = "0.1.0"
dependencies = [
"anyhow",
"bikecontrol-ble",
"bikecontrol-core",
"roxmltree",
"serde",
@@ -131,6 +132,7 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]
+71 -5
View File
@@ -13,14 +13,19 @@ See [REQUIREMENTS.md](REQUIREMENTS.md) for the full specification.
| Component | State |
|-----------|-------|
| `crates/core` — physics, profiles, GPX import, ride engine | Implemented, 103 tests |
| `crates/ble` — FTMS client for the D100 | Implemented, 96 tests. **Not yet wired into the app** |
| `crates/ble` — FTMS client for the D100, Zwift Click protocol | Implemented, 108 tests. **FTMS and the Click are both wired into the app** |
| `crates/fit` — FIT activity encoder | Implemented, 106 tests. **Not yet wired into the app** |
| `crates/probe` — hardware discovery CLI | **Works against the real trainer** |
| `src-tauri` + `ui` — desktop app | Runs on a **mock rider**, not the real trainer |
| `src-tauri` + `ui` — desktop app | **Rides the real trainer**: scan → connect → control → telemetry |
The GUI and the trainer are both working, but **not yet connected to each other** — the
app ticks a synthetic rider while `probe` talks to the real hardware. Joining them is the
next step.
The app drives the hardware end to end: it scans over BlueZ, connects, acquires FTMS
control, feeds Indoor Bike Data into the ride engine, and writes control targets back at
4 Hz. Recording to FIT is the remaining gap.
The synthetic rider is still available for GUI work with no hardware on the desk —
`BIKECONTROL_MOCK=1`, or `BIKECONTROL_DEMO=1` which also loads the sample route and starts
riding. Both need the `mock-ride` feature, which is on by default;
`--no-default-features` produces a binary that can only ever show real trainer data.
---
@@ -79,6 +84,27 @@ cargo tauri dev
Every shortcut is mirrored by an on-screen control.
### Zwift Click
Connect from the device screen — **press a button on the pod first**, since a Click only
advertises while awake. Each button routes to the same intent as the equivalent key, so the
two can never drift apart:
| Button | Action |
|--------|--------|
| `+` / `` | Shift a gear: ±10 W in ERG, one level in resistance mode, else gradient ±0.5% |
| D-pad `↑` / `↓` | Gradient +0.5% / 0.5% |
| D-pad `←` / `→` | Device screen / ride screen |
| `A` | Pause / resume |
| `B` | Insert lap marker |
| `Y` | Cycle control mode |
| `Z` | Profiles and routes |
Shifting is **emulated app-side**. The D100 exposes no virtual-shifting command surface —
its Zwift service only streams telemetry (REQUIREMENTS.md §2.3.2) — so a "gear" is a step
in whatever target the active control mode drives. The keyboard remains the fallback if a
pod's battery dies mid-ride.
---
## Talking to the trainer
@@ -94,6 +120,7 @@ cargo build -p bikecontrol-probe
./target/debug/probe inspect --name VANRYSEL # services, characteristics, capabilities
./target/debug/probe monitor --name VANRYSEL # live telemetry: raw hex + decoded
./target/debug/probe set --name VANRYSEL sim=4.0 # apply a target, then auto-reset
./target/debug/probe zwift <ADDR> # Zwift Click: handshake, then log frames
```
`set` accepts `gradient=<pct>`, `sim=<pct>`, `resistance=<level>` or `power=<watts>`, and
@@ -123,6 +150,45 @@ Note `SetTargetInclination (0x03)` is **not** supported, and its range character
reports only 06% with no negatives — so simulation mode is the only usable path for
gradient.
### What the Zwift Click v2 reports
Confirmed against the hardware — the pods talk to us **unencrypted**:
```
Zwift Click (two pods, one BLE peripheral each)
advertises 0xFC82, manufacturer 0x094a: 0a… and 0b… <-- type byte per pod
service 0xFC82 wraps the familiar Zwift characteristics:
00000002-19ca-… notify device events
00000003-19ca-… write-without-response commands in
00000004-19ca-… read, indicate responses out
00000100/0101/0102-19ca-… undocumented, silent so far
-> 526964654f6e0009 ("RideOn" + 00 09)
<- 526964654f6e0203 ("RideOn" + 02 03) no key exchange, no encryption
<- 191064 battery 100%
<- 2308f7ffffff0f buttons: mask 0xfffffff7, bit 3 held
```
Two things differ from the public write-ups: the v2 puts everything under `0xFC82` rather
than the trainer's `00000001-19ca-…` service, and it reports buttons as a **32-bit
active-low bitmask** (`0x23`) rather than the documented two-varint `0x37` message. Idle is
`0xffffffff`; a clear bit means pressed.
The bit map is confirmed against the hardware:
| Bit | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 |
|-----|---|---|---|---|---|---|---|---|---|----|
| Button | `left` | `up` | `right` | `down` | `A` | `B` | `Y` | `Z` | `` | `+` |
D-pad on 03, face buttons on 47, paddles at 8 and 12; bits 911 are unclaimed. Use
`probe zwift <ADDR> --buttons` to see named presses one line at a time:
```
[ 3.51s] PRESS #1 A (bit 4) mask 0xffffffef
[ 5.44s] RELEASE --- mask 0xffffffff
[ 6.16s] PRESS #2 Z (bit 7) mask 0xffffff7f
```
---
## Profiles
+131 -18
View File
@@ -72,6 +72,33 @@ this trainer (§3.2).
> `SetIndoorBikeSimulationParameters` (`0x11`). TASK-1 resolves whether `0x11` works. Low
> impact either way, because the app owns the physics (FR-7.1).
### 2.1.1 Cadence is on the Zwift channel, not FTMS ✅ *(measured 2026-08-05)*
The D100's Fitness Machine Feature bits (`014000000ca00000`) advertise average
speed and power only — **no cadence** — and that is accurate for FTMS. The ten
undeclared trailing bytes in its Indoor Bike Data were investigated and carry no
cadence either: the only varying field is a fixed **73.8x the declared speed**
(wheel RPM restated), constant to 0.5% from 1.7 to 29.7 km/h.
Cadence is nevertheless available, on the **trainer's Zwift service**. Message
type `0x03` at ~1 Hz, five protobuf varints, of which two are independent:
| field | meaning | note |
|-------|---------|------|
| 1 | instantaneous power, W | verified: ~120 W then ~350 W across two effort phases |
| 2 | heart rate | always 0 with no strap paired |
| 3 | **cadence, 0.1 rpm** | verified: 620 -> 62.0 rpm, 1030 -> 103.0 rpm against held cadences |
| 4 | duplicate of field 1 | ratio exactly 1.00 on every frame |
| 5 | speed | a fixed **28.6x field 3** |
Field 5 being a constant multiple of field 3 is the *signature of the Cog*: one
sprocket means cadence and wheel speed are mechanically locked, so the ratio is
the gearing. Observing that constant is what identifies the pair.
Decoded by `bikecontrol_ble::zwift::decode_riding_data`, tested against captured
frames. **This unlocks torque-based virtual gearing** (tau = P/omega), replacing the
provisional gradient-offset table in `crates/core/src/gearing.rs`.
### 2.2 Zwift Cog
A single 14T cog — **no mechanical gears**. This is why virtual shifting (§5.4) is a core
@@ -100,22 +127,91 @@ The rider's procedure — **free, no paid subscription required**:
> implementation lives in a **private submodule**. We therefore assume the manual daily
> unlock. Replicating the keep-alive is explicitly out of scope; see §1.2.
### 2.3.1 Zwift BLE protocol
### 2.3.1 Zwift BLE protocol ✅ **TASK-0 answered**
Established from three independent open-source implementations (§3.43.6).
Originally established from three open-source implementations (§3.43.6), then **confirmed
against our own pods on 2026-08-05** with `probe zwift`. Where the two disagree, the
hardware wins and the difference is called out below.
| Item | Value |
|------|-------|
| Custom service | `00000001-19CA-4651-86E5-FA29DCDD09D1` |
| Async (notify) | `00000002-…` — button notifications |
| Sync RX (write) | `00000003-…`commands to device |
| Sync TX (indicate) | `00000004-…`responses |
| Unknown (indicate/read/write) | `00000006-…`purpose undetermined |
| Manufacturer ID | 2378 (`0x094A`); device byte `0x09` = Click v1, `0x0A`/`0x0B` = v2 |
| Handshake | Write `RideOn` (`52 69 64 65 4F 6E`) + 2 bytes to Sync RX; device replies on Sync TX |
| Message types | `0x07` controller notification · `0x15` empty/keepalive · `0x19` battery · `0x37` Click button state (two protobuf varints) |
| Item | Value | Confidence |
|------|-------|------------|
| Service **on the Click v2** | `0000FC82-0000-1000-8000-00805F9B34FB` | **Confirmed** |
| Service **on the D100 trainer** | `00000001-19CA-4651-86E5-FA29DCDD09D1` | **Confirmed** |
| Async (notify) | `00000002-19CA-…`device events | **Confirmed** |
| Sync RX (write-without-response) | `00000003-19CA-…`commands to device | **Confirmed** |
| Sync TX (read/indicate) | `00000004-19CA-…`responses | **Confirmed** |
| `00000100-…`, `00000101-…` (write/notify), `00000102-…` (write-without-response/notify) | undocumented, silent so far | **Confirmed present** |
| `00000006-…` | **absent** on our v2 | — |
| Manufacturer ID | 2378 (`0x094A`) | **Confirmed** |
| Device type byte | `0x0A` and `0x0B`**one per pod, not a version marker** | **Confirmed** |
| Handshake | Write `52 69 64 65 4F 6E 00 09` to Sync RX | **Confirmed** |
| Handshake reply | `52 69 64 65 4F 6E 02 03` on Sync TX — `02 03`, **not** the documented `01 03` | **Confirmed** |
| Button state | `0x23` + one protobuf varint: a **32-bit active-low bitmask** | **Confirmed** |
| Battery | `0x19`; `19 10 64` = 100% | **Confirmed** |
| `0x37` two-varint button message | **never seen** on our v2 — presumably v1 | Superseded |
**Encryption.** The handshake performs a key exchange and messages are then encrypted:
> **The two services are not interchangeable.** The Click v2 wraps the *same* `…-19CA-…`
> characteristics in the SIG-assigned service `0xFC82`; the trainer uses the original custom
> service UUID. Discovery must accept either.
**Button bitmask.** Idle is `0xFFFFFFFF`; a **clear** bit means pressed, and the pod streams
at roughly 10 Hz while anything is held. Two bits can be clear at once, so it is live state,
not an event code.
**Bit map — confirmed 2026-08-05.** Established by pressing each button in a known order,
reproduced across two full runs plus targeted spot checks:
| Bit | Button | Mask when held | | Bit | Button | Mask when held |
|-----|--------|----------------|---|-----|--------|----------------|
| 0 | `left` | `0xFFFFFFFE` | | 5 | `B` | `0xFFFFFFDF` |
| 1 | `up` | `0xFFFFFFFD` | | 6 | `Y` | `0xFFFFFFBF` |
| 2 | `right` | `0xFFFFFFFB` | | 7 | `Z` | `0xFFFFFF7F` |
| 3 | `down` | `0xFFFFFFF7` | | 8 | `` paddle | `0xFFFFFEFF` |
| 4 | `A` | `0xFFFFFFEF` | | 12 | `+` paddle | `0xFFFFEFFF` |
D-pad on 03, face buttons on 47, paddles at 8 and 12. **Bits 911 are unclaimed** — no
button we have found drives them.
> `A` and `Z` disagreed between the first two captures and were settled by an alternating
> two-button test returning 4, 7, 4, 7, 4, 7. The first capture had been pressed out of
> order.
> **Open:** all ten buttons arrived over the **single** pod at `f4:c4:59:03:a1:8e`
> (type byte `0x0B`). What the second pod (`c0:4a:0e:f9:a8:78`, `0x0A`) contributes is
> unknown — it may mirror the same state, or carry nothing we need.
### 2.3.2 The D100's own Zwift service — telemetry, not shifting
The trainer answers the same handshake (`RideOn 00 09``RideOn 02 00`) on the original
`00000001-19CA-…` service, and then streams a `0x03` message at ~1 Hz:
| Field | Pedalling | Coasting | Reading |
|-------|-----------|----------|---------|
| 1 | 177 → 180 → 120 | 6 | **Instantaneous power, watts** |
| 2 | 0 | 0 | always zero |
| 3 | 558 → 601 → 621 | 174 | monotonic counter |
| 4 | 177 → 180 → 120 | 6 | mirrors field 1 exactly |
| 5 | 15968 → 17184 → 17748 | 4976 | monotonic counter, constant 28.6× ratio to field 3 |
Fields 3 and 5 rise monotonically at a fixed ratio, so they are cumulative counters
(distance and revolutions, in unidentified units), not gears.
**Conclusion: this is a read channel.** Nothing here is a virtual-shifting *command*
surface, and no gear field appears. Establishing whether the trainer would *accept* a gear
command means fuzzing unknown writes to a device that controls resistance under a rider —
open-ended and not obviously safe. **Virtual shifting is therefore emulated app-side**
(§5.4), which the physics model was always designed for.
> Corroboration: `qdomyos-zwift` [#3678](https://github.com/cagnulein/qdomyos-zwift/issues/3678)
> reports Click shifting on a D100 dying after 3060 s with the pod's LED dropping out —
> the §2.3 unlock-timeout signature. Labelled `wontfix`. Our unencrypted sessions ran past
> **three minutes** with button edges still arriving, so this path does not appear to hit
> that timeout — but a long ride is the real test.
**Encryption — not required.****A-3 holds for the v2.** The pod completed the handshake
and streamed button and battery events with **no key exchange and no encryption at all**.
The ECDH P-256 → HKDF → AES-256-CCM path documented below was therefore *not* needed, and
no crypto crate has been added.
| Stage | Mechanism |
|-------|-----------|
@@ -124,12 +220,14 @@ Established from three independent open-source implementations (§3.43.6).
| Cipher | **AES-256-CCM**, 4-byte MAC |
| Framing | 4-byte big-endian counter prepended per message; nonce = IV ‖ counter |
All of this maps onto pure-Rust RustCrypto crates (`p256`, `hkdf`, `sha2`, `aes`, `ccm`),
so no C dependency is needed.
Retained only in case a firmware update closes the unencrypted path. It maps onto pure-Rust
RustCrypto crates (`p256`, `hkdf`, `sha2`, `aes`, `ccm`), so reinstating it needs no C
dependency.
> **A-3:** At least one implementation connects to a Click **without** encryption and still
> receives button and battery events (§3.6). Whether that holds for a v2 is unknown —
> TASK-0 tests the unencrypted path first, since it would be far simpler.
> **Open:** whether the daily Zwift-app unlock (§2.3) is needed at all on this path is
> **untested** — the successful session followed recent Zwift use, so it cannot yet be
> distinguished from an unlock that was simply still valid. Re-test after 24 hours away
> from the Zwift app.
### 2.4 Environmental assumptions
@@ -280,6 +378,16 @@ bikecontrol/
| FR-1.7 | Surface per-device connection state (scanning / connecting / connected / lost) | Must |
| FR-1.8 | When nothing is found, prompt to wake the device (per A-4) — pedal the trainer, press a Click button | Must |
| FR-1.9 | Allow riding with the trainer alone; on-screen and keyboard controls substitute | Must |
| FR-1.10 | A connect or reconnect attempt in flight is cancellable: disconnect and app exit abandon it rather than queue behind it, and any half-open GATT link is closed on the way out | Must |
| FR-1.11 | Auto-reconnect is bounded. On giving up, the trainer settles in `Lost` carrying the reason — never silently back to `Idle` | Must |
| FR-1.12 | Scanning resumes on its own whenever no trainer link is held or being attempted, so a disconnect or a failed connect does not leave a frozen device list | Should |
**Cancellation note (FR-1.10):** a connect is a long operation — up to `scan_timeout` before the
peripheral is even found, then connect, service discovery and the control handshake. Waiting for
it to finish before honouring a disconnect or a quit is what makes SAF-2 miss its budget, so the
attempt must be abandoned instead. Abandoning is not free: the D100 accepts one BLE host (A-3),
so an attempt that had already opened a link must close it, or the trainer stays unreachable
until it times out on its own.
### 5.2 Trainer control (FR-2)
@@ -500,6 +608,7 @@ tests, and removes dependence on the trainer's internal mass assumptions.
| FR-9.10 | On-screen and keyboard equivalents for all controller actions | Must |
| FR-9.11 | Instantaneous power is noisy — show a rolling average alongside or instead | Should |
| FR-9.12 | Dark theme suitable for indoor training | Should |
| FR-9.16 | Estimated energy expenditure in kcal, from measured work and rider mass. One model shared by the live readout and the exported FIT, so the two agree; the trainer's own energy field is not used, because FTMS does not define what it means | Should |
**Post-ride**
@@ -522,6 +631,8 @@ tests, and removes dependence on the trainer's internal mass assumptions.
| NFR-6 | **Offline** — full functionality with no internet connection. The app never contacts Zwift or any other service; only the rider's separate daily unlock needs the internet |
| NFR-7 | **Startup** — launch to scanning in under 3 seconds |
| NFR-8 | **Observability** — all BLE traffic loggable at debug level, including decrypted Click frames, for protocol diagnosis |
| NFR-9 | **Shutdown** — every exit path completes the SAF-2 sequence and closes within 8 seconds, whatever the radio was doing when the rider quit |
| NFR-10 | **No busy-waiting** — a supervisor whose event source has closed drops it. No loop may spin on a permanently-ready future, and no arm of a `biased` select may starve the one that carries the shutdown command |
---
@@ -536,6 +647,8 @@ tests, and removes dependence on the trainer's internal mass assumptions.
| SAF-5 | Never issue a step larger than one configured increment per input event |
| SAF-6 | Waveform parameter errors must not command an unsafe target (enforced by SAF-3) |
| SAF-7 | A stale or replayed Click frame must not re-trigger an action — enforce the session counter and reject out-of-order frames |
| SAF-8 | The shutdown sequence preempts in-flight BLE work. A connect or reconnect is abandoned, and every step of the reset is individually bounded, so SAF-2 finishes inside the NFR-9 budget rather than timing out |
| SAF-9 | Every BLE link the app owns — trainer *and* controller — is explicitly closed on exit. A link the process merely abandons can leave the peripheral held and unreachable on the next launch (A-3) |
---
+499
View File
@@ -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(&current).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(&current).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 }
);
}
}
+233 -7
View File
@@ -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 {
// 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 {
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 {
+8
View File
@@ -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
View File
@@ -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
+93
View File
@@ -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 2025%; [`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);
}
}
+234
View File
@@ -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);
}
}
+220
View File
@@ -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.53 m: that is the smoothing window's problem to solve,
/// not this one's. Set below the errors that actually matter, which are the
/// 510 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 49%, 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!(
+3
View File
@@ -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
View File
@@ -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 {
+80 -1
View File
@@ -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);
}
}
+32
View File
@@ -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(),
}
}
}
+76 -45
View File
@@ -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![
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: 0,
elapsed_ms: i * 1000,
power_w: Some(200),
energy_kcal: Some(10),
// A trainer insisting the rider burned 200 kcal in a minute.
energy_kcal: Some(10 + (i * 200 / 60) as u16),
..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 },
];
})
})
.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]
+6
View File
@@ -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,
}
}
+5
View File
@@ -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
View File
@@ -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`"),
};
+389 -1
View File
@@ -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
// ---------------------------------------------------------------------------
+8
View File
@@ -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
}
}
}
+75
View File
@@ -0,0 +1,75 @@
# Maintainer: Duncan Tourolle <duncan@tourolle.paris>
#
# BikeControl — indoor cycling trainer control (Tauri + Svelte + Rust).
#
# Tauri's bundler has no pacman target, so this PKGBUILD builds a real
# .pkg.tar.zst with makepkg. It builds from the *local working tree* (pointed at
# by $BIKECONTROL_SRC), which is what scripts/build-arch.sh sets up. For AUR
# distribution, replace this with a source=() release tarball / VCS URL.
pkgname=bikecontrol
# scripts/build-arch.sh exports BIKECONTROL_PKGVER, derived from git: the tag on
# master/main, the branch name on a feature branch. The fallback below only
# applies when makepkg is run by hand outside that script.
pkgver="${BIKECONTROL_PKGVER:-0.1.0}"
pkgrel=1
pkgdesc="Indoor cycling trainer control: ride gradient profiles over BLE FTMS and record to FIT"
arch=('x86_64')
url="https://github.com/dtourolle/BikeControl"
license=('MIT')
# Runtime: webkit2gtk for the webview, gtk3 for the shell window.
depends=('webkit2gtk-4.1' 'gtk3')
makedepends=('rust' 'npm' 'nodejs' 'pkgconf')
optdepends=('bluez: talk to a real smart trainer over BLE'
'bluez-utils: bluetoothctl, for pairing and troubleshooting')
# !lto: the release profile already enables Rust LTO; makepkg's C LTO flags only
# risk mismatches with cc-rs-built deps. !strip: keep it consistent with what
# `cargo build --release` produced.
options=('!strip' '!lto')
# Populated from the working tree by scripts/build-arch.sh.
_srcdir="${BIKECONTROL_SRC:-$startdir/../..}"
build() {
cd "$_srcdir"
export CARGO_HOME="${CARGO_HOME:-$srcdir/cargo-home}"
# The frontend must exist before cargo builds: tauri.conf.json points
# frontendDist at ../ui/dist and tauri-build embeds it into the binary.
# `cargo build` does not run beforeBuildCommand, so build the UI here.
npm --prefix ui ci || npm --prefix ui install
npm --prefix ui run build
# Only the raw binary is needed; the Arch filesystem layout is done in
# package() below rather than via tauri-bundler.
cargo build --release --locked -p bikecontrol-app
}
package() {
cd "$_srcdir"
# Cargo names the binary after the crate (bikecontrol-app); install it under
# the shorter user-facing name the .desktop entry execs.
install -Dm755 "target/release/bikecontrol-app" \
"$pkgdir/usr/bin/bikecontrol"
install -Dm644 "packaging/arch/bikecontrol.desktop" \
"$pkgdir/usr/share/applications/bikecontrol.desktop"
# Icons (hicolor)
install -Dm644 "src-tauri/icons/32x32.png" \
"$pkgdir/usr/share/icons/hicolor/32x32/apps/bikecontrol.png"
install -Dm644 "src-tauri/icons/128x128.png" \
"$pkgdir/usr/share/icons/hicolor/128x128/apps/bikecontrol.png"
install -Dm644 "src-tauri/icons/128x128@2x.png" \
"$pkgdir/usr/share/icons/hicolor/256x256/apps/bikecontrol.png"
# Example ride profiles, so a fresh install has something to load.
for _p in profiles/*.yaml; do
install -Dm644 "$_p" "$pkgdir/usr/share/bikecontrol/profiles/$(basename "$_p")"
done
if [ -f LICENSE ]; then
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
fi
}
+10
View File
@@ -0,0 +1,10 @@
[Desktop Entry]
Type=Application
Name=BikeControl
Comment=Indoor cycling trainer control
Exec=bikecontrol
Icon=bikecontrol
Terminal=false
Categories=Utility;
Keywords=cycling;trainer;ftms;bluetooth;fit;
StartupWMClass=bikecontrol-app
Executable
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Launch BikeControl. Always builds first via `cargo tauri build`, because a
# plain `cargo build` produces a binary wired to localhost:1420 that shows a
# BLACK WINDOW unless a Vite dev server happens to be running. Incremental
# builds take seconds, and this removes a whole class of confusion.
set -e
ROOT="$(cd "$(dirname "$0")" && pwd)"
LOG="$ROOT/bikecontrol.log"
# WebKitGTK 2.52 on Wayland + Intel paints black rectangles via its DMABUF
# renderer; disabling it costs nothing here (the UI is 2D canvas and CSS).
export WEBKIT_DISABLE_DMABUF_RENDERER=1
export WEBKIT_DISABLE_COMPOSITING_MODE=1
export RUST_LOG="${RUST_LOG:-debug,btleplug=info,tao=warn,wry=warn}"
echo "building (embeds the frontend — this is what avoids the black window)..."
( cd "$ROOT/src-tauri" && cargo tauri build --debug --no-bundle ) 2>&1 | tail -3
BIN="$ROOT/target/debug/bikecontrol-app"
echo "=== BikeControl $(date '+%H:%M:%S') ===" > "$LOG"
echo "launching $BIN — logging to $LOG"
exec "$BIN" "$@" 2>&1 | tee -a "$LOG"
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env bash
# Build (and by default install) an Arch Linux package for BikeControl.
#
# Tauri's bundler has no pacman target, so we ship a hand-written PKGBUILD in
# packaging/arch/ and build it with makepkg. makepkg is Arch-specific and
# refuses to run as root, so run this as your normal user — the install step
# calls sudo pacman itself.
#
# The version comes from git: the tag on master/main, the branch name elsewhere.
#
# Usage:
# scripts/build-arch.sh # build, then pacman -U the result
# scripts/build-arch.sh --no-install # build only (CI / packaging runs)
# scripts/build-arch.sh --sync-deps # let makepkg -s pacman-install makedeps
# scripts/build-arch.sh --print-version # show the version that would be built
# OUTPUT_DIR=dist scripts/build-arch.sh # also copy the .pkg.tar.zst there
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
INSTALL=1
SYNC_DEPS=0
PRINT_ONLY=0
for arg in "$@"; do
case "$arg" in
--no-install) INSTALL=0 ;;
--install) INSTALL=1 ;;
--sync-deps) SYNC_DEPS=1 ;;
--print-version) PRINT_ONLY=1 ;;
-h|--help) sed -n '2,15p' "${BASH_SOURCE[0]}"; exit 0 ;;
*) echo "❌ Unknown option: $arg (try --help)" >&2; exit 2 ;;
esac
done
[[ $PRINT_ONLY -eq 1 ]] || command -v makepkg >/dev/null || {
echo "❌ makepkg not found — this script must run on an Arch-based system." >&2
exit 1
}
[[ $PRINT_ONLY -eq 1 || $EUID -ne 0 ]] || {
echo "❌ Do not run this as root; makepkg refuses to build as root." >&2
exit 1
}
# --- Version -----------------------------------------------------------------
# Release builds (master/main, or a detached checkout as CI does on a tag) take
# their version from the git tag. Branch builds take it from the branch name, so
# a package sitting in /var/cache/pacman or `pacman -Qi bikecontrol` says which
# branch it came from. Both get an .r<commits>.g<sha> suffix so two builds of the
# same branch/tag are distinguishable and sort in commit order.
#
# pacman forbids hyphens, colons, slashes and whitespace in a pkgver, so branch
# and tag names are sanitised to underscores. Note that pacman's vercmp ranks a
# leading digit above a leading letter, so installing a branch build on top of a
# release build reads as a "downgrade" — harmless, and --noconfirm accepts it.
_cargo_version() {
sed -n '/^\[workspace\.package\]/,/^\[/ s/^version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' \
"$REPO_ROOT/Cargo.toml" | head -1
}
_sanitize() { printf '%s' "$1" | sed 's/[^A-Za-z0-9._+]/_/g'; }
# Sets the globals PKGVER and VERSION_SOURCE (rather than echoing, so the
# "where did this version come from" note survives out of a subshell).
derive_pkgver() {
local cargover branch desc tag rest count sha
cargover="$(_cargo_version)"
[[ -n "$cargover" ]] || { echo "❌ Could not read version from Cargo.toml" >&2; exit 1; }
if ! git -C "$REPO_ROOT" rev-parse --git-dir >/dev/null 2>&1; then
VERSION_SOURCE="Cargo.toml (not a git checkout)"
PKGVER="$cargover"; return
fi
count="$(git -C "$REPO_ROOT" rev-list --count HEAD 2>/dev/null || echo 0)"
sha="g$(git -C "$REPO_ROOT" rev-parse --short HEAD 2>/dev/null || echo unknown)"
# Empty on a detached HEAD — that is how CI checks out a tag, so treat it as a
# release build rather than a branch build.
branch="$(git -C "$REPO_ROOT" symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
if [[ -z "$branch" || "$branch" == master || "$branch" == main ]]; then
if desc="$(git -C "$REPO_ROOT" describe --tags --long 2>/dev/null)"; then
# v0.2.0-3-gabc1234 -> tag=v0.2.0, count=3. Strips the trailing two
# hyphen-separated fields, so a tag containing hyphens survives intact.
tag="${desc%-*-*}"; rest="${desc#"$tag"-}"; count="${rest%-*}"; sha="${rest#*-}"
VERSION_SOURCE="git tag ${desc%-*-*}"
tag="$(_sanitize "${tag#v}")"
if [[ "$count" == 0 ]]; then
PKGVER="$tag"
else
VERSION_SOURCE="$VERSION_SOURCE +$count commits"
PKGVER="$tag.r$count.$sha"
fi
else
VERSION_SOURCE="Cargo.toml (no tags yet)"
PKGVER="$cargover.r$count.$sha"
fi
else
VERSION_SOURCE="branch $branch"
PKGVER="$(_sanitize "$branch").r$count.$sha"
fi
}
# BIKECONTROL_PKGVER may be set by the caller (CI) to pin the version exactly.
if [[ -n "${BIKECONTROL_PKGVER:-}" ]]; then
PKGVER="$BIKECONTROL_PKGVER"
VERSION_SOURCE="\$BIKECONTROL_PKGVER"
else
derive_pkgver
fi
if [[ $PRINT_ONLY -eq 1 ]]; then
echo "$PKGVER"
echo "(from $VERSION_SOURCE)" >&2
exit 0
fi
echo "🚴 Building BikeControl Arch package $PKGVER"
echo " version from: $VERSION_SOURCE"
echo "============================================="
# Build straight out of the working tree; give cargo a writable, repo-local home
# so a makepkg run never fights with the user's ~/.cargo permissions.
export BIKECONTROL_SRC="$REPO_ROOT"
export BIKECONTROL_PKGVER="$PKGVER"
export CARGO_HOME="${CARGO_HOME:-$REPO_ROOT/.cargo-arch}"
if [[ $SYNC_DEPS -eq 0 ]]; then
# Without -s, makepkg only checks that the makedeps are present. rustup users
# have cargo on PATH without the `rust` package installed, so warn rather than
# drag in a duplicate toolchain.
for tool in cargo npm node pkg-config; do
command -v "$tool" >/dev/null || echo "⚠️ $tool not on PATH — build may fail (or rerun with --sync-deps)"
done
fi
cd "$REPO_ROOT/packaging/arch"
MAKEPKG_ARGS=(-f --noconfirm --nodeps)
[[ $SYNC_DEPS -eq 1 ]] && MAKEPKG_ARGS=(-sf --noconfirm)
makepkg "${MAKEPKG_ARGS[@]}"
PKG="$(ls -1t ./*.pkg.tar.zst | head -1)"
echo ""
echo "✅ Built $PKG"
if [[ -n "${OUTPUT_DIR:-}" ]]; then
mkdir -p "$OUTPUT_DIR"
cp -v "$PKG" "$OUTPUT_DIR/"
echo "📦 Copied to $OUTPUT_DIR"
fi
if [[ $INSTALL -eq 1 ]]; then
echo ""
echo "📥 Installing $PKG (sudo pacman -U)"
sudo pacman -U --noconfirm "$PKG"
echo ""
echo "✅ Installed. Run it with: bikecontrol"
echo " Example profiles: /usr/share/bikecontrol/profiles/"
else
echo ""
echo "️ Skipped install. To install manually: sudo pacman -U $PWD/${PKG#./}"
fi
+10 -5
View File
@@ -17,10 +17,12 @@ tauri-build = { version = "2", features = [] }
[dependencies]
bikecontrol-core = { workspace = true }
bikecontrol-ble = { workspace = true }
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
uuid = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
serde_yaml_ng = { workspace = true }
@@ -33,9 +35,12 @@ tracing-subscriber = { workspace = true }
[features]
default = ["mock-ride"]
# Drive the UI from the synthetic ride simulator in `src/mock.rs`. This is what
# ships today, while `crates/core` and `crates/ble` are still being written.
# Compile the synthetic rider in `src/mock.rs` *as an option*. It is no longer
# the default data source — `bikecontrol_core::RideSession` fed by real FTMS
# telemetry is (see `state::Inner::new`). The feature exists so the mock can be
# selected at runtime with `BIKECONTROL_DEMO=1` or `BIKECONTROL_MOCK=1`, which
# is what makes the GUI developable with no hardware on the desk.
#
# Build with `--no-default-features` for a binary that can only ever show real
# trainer data.
mock-ride = []
# Drive the UI from `bikecontrol_core::RideSession` fed by real FTMS telemetry.
# Swap `default` to this once core's `session`/`physics` are implemented.
real-session = []
+9 -4
View File
@@ -1,9 +1,11 @@
//! The seam between the Tauri shell and whatever is actually riding.
//!
//! Today that is [`crate::mock::MockBackend`], a synthetic rider. Tomorrow it
//! is `bikecontrol_core::RideSession` fed by FTMS telemetry from
//! `bikecontrol_ble`. Both are the same shape: rider intent in, a
//! `RideSnapshot` out, and a `ControlTarget` to push to the trainer.
//! By default that is [`crate::session_backend::SessionBackend`] —
//! `bikecontrol_core::RideSession` fed by real FTMS telemetry from
//! `bikecontrol_ble`. `crate::mock::MockBackend`, a synthetic rider, is the
//! opt-in alternative for working on the GUI with no hardware. Both are the
//! same shape: rider intent in, a `RideSnapshot` out, and a `ControlTarget` to
//! push to the trainer.
//!
//! Nothing above this trait knows which one is running (§4.3 — the control loop
//! lives in Rust; the frontend only ever sees snapshots).
@@ -26,6 +28,8 @@ pub struct RideInputs {
pub manual_gradient_pct: f32,
/// Trim applied on top of whatever the base gradient is (FR-4.2).
pub gradient_offset_pct: f32,
/// Selected virtual gear, one-based (FR-4.1).
pub gear: usize,
pub resistance_level: i16,
pub power_target_w: u16,
pub profile: Option<Arc<Profile>>,
@@ -38,6 +42,7 @@ impl Default for RideInputs {
Self {
status: RideStatus::Idle,
mode: ControlMode::ManualGrade,
gear: bikecontrol_core::Gearing::default().gear(),
manual_gradient_pct: 0.0,
gradient_offset_pct: 0.0,
resistance_level: 20,
+37
View File
@@ -9,6 +9,9 @@ use bikecontrol_core::profile::Profile;
use bikecontrol_core::types::{ControlMode, RiderConfig, SafetyLimits};
use tauri::{AppHandle, State};
use bikecontrol_ble::TrainerSelector;
use crate::controller::ControllerStatus;
use crate::devices::DeviceInfo;
use crate::events::{DeviceList, LapSummary, Notice, RideState, RideStatus};
use crate::profile_view::{self, ProfileView};
@@ -421,3 +424,37 @@ pub fn forget_device(app: AppHandle, state: State<'_, AppState>, device_id: Stri
pub fn trainer_controllable(state: State<'_, AppState>) -> bool {
state.lock().devices.trainer_controllable()
}
// ---------------------------------------------------------------------------
// Controller (Zwift Click)
// ---------------------------------------------------------------------------
#[tauri::command]
pub fn controller_status(state: State<'_, AppState>) -> ControllerStatus {
state.controller().status()
}
/// Connect to a Click. `device_id` is an address; omit it to take the first pod
/// that advertises.
///
/// Fire-and-forget: the supervisor owns the radio and the result arrives on
/// `controller://status`. A Click sleeps within seconds and only advertises
/// after a button press (A-4), so this routinely takes a few attempts — which
/// is why it must not block the UI thread waiting for one.
#[tauri::command]
pub fn connect_controller(state: State<'_, AppState>, device_id: Option<String>) -> Cmd<()> {
let selector = match device_id {
Some(id) if !id.trim().is_empty() => TrainerSelector::Address(id),
// Every pod so far advertises as "Zwift Click".
_ => TrainerSelector::NameContains("Zwift Click".into()),
};
state.controller().connect(selector);
Ok(())
}
#[tauri::command]
pub fn disconnect_controller(app: AppHandle, state: State<'_, AppState>) -> Cmd<()> {
state.controller().disconnect();
notify(&app, Notice::info("Controller disconnected"));
Ok(())
}
+397
View File
@@ -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());
}
}
+102 -13
View File
@@ -17,6 +17,7 @@
use std::collections::VecDeque;
use bikecontrol_core::energy;
use bikecontrol_core::profile::Profile;
use bikecontrol_core::types::RideSnapshot;
use serde::Serialize;
@@ -84,6 +85,10 @@ pub struct Derived {
pub normalised_power_w: Option<f32>,
pub avg_cadence_rpm: f32,
pub energy_kj: f32,
/// Estimated metabolic cost of the ride so far, kcal. See
/// [`bikecontrol_core::energy`] — this is the rider's burn, not the
/// mechanical work in `energy_kj`.
pub calories_kcal: f32,
}
/// Rolling windows. One instance lives in the app state for the whole ride.
@@ -99,6 +104,9 @@ pub struct Deriver {
cadence_n: u64,
max_power_w: i16,
energy_kj: f32,
/// Seconds the ride has actually been running. Drives the resting-burn
/// half of the calorie estimate, so a paused ride does not accrue.
active_s: f64,
last_elapsed_s: f64,
/// Last ETA that was computed from real movement (FR-9.15, hold-on-stop).
last_eta_s: Option<f64>,
@@ -118,6 +126,7 @@ impl Default for Deriver {
cadence_n: 0,
max_power_w: 0,
energy_kj: 0.0,
active_s: 0.0,
last_elapsed_s: 0.0,
last_eta_s: None,
}
@@ -148,10 +157,14 @@ impl Deriver {
}
/// Fold one snapshot in and produce the derived figures.
///
/// `rider_kg` is the configured rider mass; it only feeds the calorie
/// estimate, and zero simply means the resting term is skipped.
pub fn update(
&mut self,
snapshot: &RideSnapshot,
running: bool,
rider_kg: f32,
profile: Option<&Profile>,
geom: Option<&ProfileGeometry>,
) -> Derived {
@@ -175,6 +188,7 @@ impl Deriver {
self.cadence_n += 1;
}
self.energy_kj += power * dt as f32 / 1000.0;
self.active_s += dt;
// Normalised power: 30 s rolling mean, raised to the fourth,
// averaged, fourth root.
let rolling = mean(&self.np) as f64;
@@ -281,6 +295,11 @@ impl Deriver {
(self.cadence_sum / self.cadence_n as f64) as f32
},
energy_kj: self.energy_kj,
calories_kcal: energy::kcal(
f64::from(self.energy_kj) * 1000.0,
rider_kg,
self.active_s,
) as f32,
}
}
}
@@ -302,6 +321,9 @@ mod tests {
use crate::profile_view;
/// The default rider mass, so the calorie term is exercised everywhere.
const RIDER_KG: f32 = 75.0;
fn snapshot(elapsed_s: f64, distance_m: f64, speed_kph: f32) -> RideSnapshot {
RideSnapshot {
elapsed_ms: (elapsed_s * 1000.0) as u64,
@@ -350,7 +372,7 @@ mod tests {
let profile = timed_profile();
let (_, geom) = profile_view::build(&profile, "test");
let mut d = Deriver::default();
let out = d.update(&snapshot(120.0, 0.0, 0.0), true, Some(&profile), Some(&geom));
let out = d.update(&snapshot(120.0, 0.0, 0.0), true, RIDER_KG, Some(&profile), Some(&geom));
assert_eq!(out.eta_kind, EtaKind::Exact);
assert!((out.time_remaining_s.unwrap() - 480.0).abs() < 1e-6);
}
@@ -365,13 +387,25 @@ mod tests {
let mut t = 0.0;
for i in 1..=(SPEED_WINDOW_S / 0.25) as u32 {
t = i as f64 * 0.25;
d.update(&snapshot(t, t * 10.0, 36.0), true, Some(&profile), Some(&geom));
d.update(&snapshot(t, t * 10.0, 36.0), true, RIDER_KG, Some(&profile), Some(&geom));
}
t += 0.25;
let steady = d.update(&snapshot(t, t * 10.0, 36.0), true, Some(&profile), Some(&geom));
let steady = d.update(
&snapshot(t, t * 10.0, 36.0),
true,
RIDER_KG,
Some(&profile),
Some(&geom),
);
t += 0.25;
// One absurd sample: 90 km/h, two and a half times reality.
let spike = d.update(&snapshot(t, t * 10.0, 90.0), true, Some(&profile), Some(&geom));
let spike = d.update(
&snapshot(t, t * 10.0, 90.0),
true,
RIDER_KG,
Some(&profile),
Some(&geom),
);
assert_eq!(spike.eta_kind, EtaKind::Estimated);
let base = steady.time_remaining_s.unwrap();
let drift = (spike.time_remaining_s.unwrap() - base).abs();
@@ -390,9 +424,15 @@ mod tests {
let mut d = Deriver::default();
for i in 1..=200 {
let t = i as f64 * 0.25;
d.update(&snapshot(t, t * 8.0, 28.8), true, Some(&profile), Some(&geom));
d.update(&snapshot(t, t * 8.0, 28.8), true, RIDER_KG, Some(&profile), Some(&geom));
}
let moving = d.update(&snapshot(50.25, 402.0, 28.8), true, Some(&profile), Some(&geom));
let moving = d.update(
&snapshot(50.25, 402.0, 28.8),
true,
RIDER_KG,
Some(&profile),
Some(&geom),
);
assert_eq!(moving.eta_kind, EtaKind::Estimated);
// Now stop dead for long enough to flush the whole speed window.
@@ -401,7 +441,13 @@ mod tests {
for i in 1..=400 {
let t = 50.25 + i as f64 * 0.25;
prev = stopped;
stopped = d.update(&snapshot(t, 402.0, 0.0), true, Some(&profile), Some(&geom));
stopped = d.update(
&snapshot(t, 402.0, 0.0),
true,
RIDER_KG,
Some(&profile),
Some(&geom),
);
}
// The contract: finite, flagged as held, and no longer changing.
assert_eq!(stopped.eta_kind, EtaKind::Held);
@@ -418,9 +464,15 @@ mod tests {
let mut d = Deriver::default();
for i in 1..=200 {
let t = i as f64 * 0.25;
d.update(&snapshot(t, t * 8.0, 28.8), true, Some(&profile), Some(&geom));
d.update(&snapshot(t, t * 8.0, 28.8), true, RIDER_KG, Some(&profile), Some(&geom));
}
let paused = d.update(&snapshot(50.25, 402.0, 28.8), false, Some(&profile), Some(&geom));
let paused = d.update(
&snapshot(50.25, 402.0, 28.8),
false,
RIDER_KG,
Some(&profile),
Some(&geom),
);
assert_eq!(paused.eta_kind, EtaKind::Held);
assert!(paused.time_remaining_s.unwrap().is_finite());
}
@@ -431,7 +483,13 @@ mod tests {
let profile = distance_profile(true);
let (_, geom) = profile_view::build(&profile, "test");
let mut d = Deriver::default();
let out = d.update(&snapshot(300.0, 4500.0, 30.0), true, Some(&profile), Some(&geom));
let out = d.update(
&snapshot(300.0, 4500.0, 30.0),
true,
RIDER_KG,
Some(&profile),
Some(&geom),
);
assert_eq!(out.eta_kind, EtaKind::Looping);
assert_eq!(out.time_remaining_s, None);
assert_eq!(out.loop_index, Some(3));
@@ -443,7 +501,7 @@ mod tests {
#[test]
fn no_profile_means_unavailable() {
let mut d = Deriver::default();
let out = d.update(&snapshot(60.0, 500.0, 30.0), true, None, None);
let out = d.update(&snapshot(60.0, 500.0, 30.0), true, RIDER_KG, None, None);
assert_eq!(out.eta_kind, EtaKind::Unavailable);
assert_eq!(out.time_remaining_s, None);
}
@@ -459,11 +517,42 @@ mod tests {
for i in 1..=40 {
snap.elapsed_ms = (i * 250) as u64;
snap.telemetry.power_w = Some(100);
d.update(&snap, true, Some(&profile), Some(&geom));
d.update(&snap, true, RIDER_KG, Some(&profile), Some(&geom));
}
snap.elapsed_ms = 10_250;
snap.telemetry.power_w = Some(600);
let out = d.update(&snap, true, Some(&profile), Some(&geom));
let out = d.update(&snap, true, RIDER_KG, Some(&profile), Some(&geom));
assert!(out.rolling_power_w < 250.0, "rolling power tracked the spike too closely");
}
/// An hour at 200 W: the work term dominates, the resting term is the
/// smaller correction on top, and the total is in the range a rider would
/// recognise from a head unit.
#[test]
fn calories_track_work_plus_a_resting_correction() {
let mut d = Deriver::default();
let mut out = None;
for i in 1..=3600 {
out = Some(d.update(&snapshot(i as f64, 0.0, 30.0), true, RIDER_KG, None, None));
}
let out = out.unwrap();
assert!((out.energy_kj - 720.0).abs() < 1.0, "work {} kJ", out.energy_kj);
// 720 kJ of work plus 75 kcal of being alive for an hour.
assert!(
(out.calories_kcal - 763.0).abs() < 5.0,
"burn {} kcal",
out.calories_kcal
);
}
/// A paused ride burns nothing this ride can claim: neither pedalling work
/// nor the resting term accrues while the clock is stopped.
#[test]
fn a_paused_ride_accrues_no_calories() {
let mut d = Deriver::default();
let running = d.update(&snapshot(60.0, 0.0, 30.0), true, RIDER_KG, None, None);
let paused = d.update(&snapshot(3600.0, 0.0, 0.0), false, RIDER_KG, None, None);
assert!(running.calories_kcal > 0.0);
assert_eq!(running.calories_kcal, paused.calories_kcal);
}
}
+441 -219
View File
@@ -1,23 +1,42 @@
//! Device discovery and connection state (FR-1, FR-9.19.3).
//!
//! `crates/ble` is not written yet, so this is a **mock scanner**: a scripted
//! set of peripherals that appear over a few seconds, with RSSI that drifts and
//! connection state machines that take realistic time to settle. It exists so
//! the connection screen can be built and judged today.
//! A real BLE scanner. A background task drives `bikecontrol_ble::scan` and
//! publishes its results on a `watch` channel; [`DeviceRegistry`] — which lives
//! inside the app's synchronous `Mutex` and therefore may never `.await` —
//! reads that channel, merges in the trainer supervisor's status, and produces
//! the `DeviceInfo` list the UI renders.
//!
//! The important behaviour it models — and the reason it is not just a static
//! list — is that **BLE connection and FTMS control acquisition are separate
//! steps** (FR-9.3). A trainer goes `Connecting → Connected → Controlling`, and
//! it can sit at `Connected` indefinitely if the control point is refused.
//! ```text
//! scan task ──watch<ScanSnapshot>──┐
//! ├──► DeviceRegistry::poll ──► DeviceInfo[]
//! trainer ──watch<TrainerStatus>─┘
//! ```
//!
//! Swapping in the real scanner means replacing [`DeviceRegistry::poll`] and
//! the two request methods with `btleplug` calls; the `DeviceInfo` the UI
//! renders does not change.
//! The behaviour that matters, and the reason `control_acquired` is a field
//! rather than a state: **BLE connection and FTMS control acquisition are
//! separate steps** (FR-9.3). A trainer goes `Connecting → Connected →
//! Controlling`, and it can sit at `Connected` indefinitely if the control
//! point is refused.
use std::collections::HashSet;
use std::time::Duration;
use bikecontrol_ble::scan::{self, DiscoveredDevice, ScanKind, ZWIFT_SERVICE};
use bikecontrol_ble::uuids;
use bikecontrol_core::types::ConnectionState;
use serde::{Deserialize, Serialize};
use tokio::sync::watch;
use uuid::Uuid;
use crate::trainer::{TrainerHandle, TrainerStatus};
/// One pass of the scanner. Long enough for a trainer to advertise, short
/// enough that the list feels live.
const SCAN_WINDOW: Duration = Duration::from_millis(2500);
/// Poll interval while scanning is switched off.
const IDLE_POLL: Duration = Duration::from_millis(400);
/// Heart Rate Service, so an HRM in the room is labelled rather than "unknown".
const HEART_RATE_SERVICE: Uuid = Uuid::from_u128(0x0000180d_0000_1000_8000_00805f9b34fb);
/// What we think a peripheral is, from its advertised services and
/// manufacturer data (FR-1.2).
@@ -33,13 +52,14 @@ pub enum DeviceKind {
Unknown,
}
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceInfo {
pub id: String,
pub name: String,
pub address: String,
/// dBm. Roughly 40 (touching) to 95 (barely there).
/// dBm. Roughly 40 (touching) to 95 (barely there). 0 when the adapter
/// did not report one.
pub rssi: i16,
pub kind: DeviceKind,
pub state: ConnectionState,
@@ -61,273 +81,475 @@ pub struct PollResult {
pub changed: bool,
/// Devices whose connection state settled this tick.
pub transitions: Vec<DeviceInfo>,
/// Trainer status, when it changed since the last poll. The caller turns
/// this into user-facing notices (FR-1.8, FR-9.4).
pub trainer_changed: Option<TrainerStatus>,
}
/// A scripted peripheral in the mock environment.
struct Simulated {
info: DeviceInfo,
/// Ticks after scan start before it shows up. Models A-4: the trainer only
/// advertises once you pedal, the Click once you press a button.
appears_after: u32,
/// Ticks remaining in the current transition, and where it lands.
pending: Option<(u32, ConnectionState, bool)>,
visible: bool,
/// What the scan task publishes.
#[derive(Debug, Clone, Default)]
pub struct ScanSnapshot {
pub devices: Vec<DiscoveredDevice>,
/// Adapter-level failure — no radio, BlueZ down. Surfaced verbatim.
pub error: Option<String>,
/// Bumped every completed pass, so `poll` can tell "same devices" from
/// "scanner has not run yet".
pub generation: u64,
}
pub struct DeviceRegistry {
devices: Vec<Simulated>,
trainer: TrainerHandle,
scan_rx: watch::Receiver<ScanSnapshot>,
scan_on: watch::Sender<bool>,
forgotten: HashSet<String>,
remembered: HashSet<String>,
/// The list published last tick, for change detection.
published: Vec<DeviceInfo>,
last_trainer: TrainerStatus,
pub scanning: bool,
ticks: u32,
rng: u64,
}
/// How long each mock transition takes, in registry ticks (2 Hz).
const CONNECT_TICKS: u32 = 3;
const CONTROL_TICKS: u32 = 3;
impl Default for DeviceRegistry {
fn default() -> Self {
Self::new()
}
/// Scanning was switched off by *us*, to get out of the way of a connect —
/// not by the rider. Only a suspension is resumed automatically (FR-1.12).
scan_suspended: bool,
/// The most recent adapter error, so the UI can say why the list is empty.
pub error: Option<String>,
}
impl DeviceRegistry {
pub fn new() -> Self {
pub fn new(trainer: TrainerHandle) -> Self {
let (scan_on, scan_on_rx) = watch::channel(false);
let (scan_tx, scan_rx) = watch::channel(ScanSnapshot::default());
tauri::async_runtime::spawn(scan_loop(scan_on_rx, scan_tx));
Self {
devices: catalogue(),
last_trainer: trainer.status(),
trainer,
scan_rx,
scan_on,
forgotten: HashSet::new(),
remembered: HashSet::new(),
published: Vec::new(),
scanning: false,
ticks: 0,
rng: 0xDEAD_BEEF_CAFE_F00D,
scan_suspended: false,
error: None,
}
}
fn rand(&mut self) -> f32 {
let mut x = self.rng;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.rng = x;
((x.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 40) as f32) / (1 << 24) as f32
}
/// Rider-initiated. Cancels any suspension: an explicit request outranks
/// our own bookkeeping in both directions.
pub fn start_scan(&mut self) {
self.scanning = true;
self.ticks = 0;
for d in &mut self.devices {
if !matches!(d.info.state, ConnectionState::Connected | ConnectionState::Controlling) {
d.info.state = ConnectionState::Scanning;
}
}
self.scan_suspended = false;
self.set_scanning(true);
}
pub fn stop_scan(&mut self) {
self.scanning = false;
for d in &mut self.devices {
if d.info.state == ConnectionState::Scanning {
d.info.state = ConnectionState::Idle;
}
}
self.scan_suspended = false;
self.set_scanning(false);
}
/// Advance the mock. Reports whether the list changed at all, and which
/// devices crossed a connection-state boundary this tick.
fn set_scanning(&mut self, on: bool) {
self.scanning = on;
let _ = self.scan_on.send(on);
}
/// Rebuild the device list from the scanner and the trainer supervisor.
pub fn poll(&mut self) -> PollResult {
let mut changed = false;
let mut transitions = Vec::new();
if self.scanning {
self.ticks += 1;
for i in 0..self.devices.len() {
let appears = self.devices[i].appears_after;
if !self.devices[i].visible && self.ticks >= appears {
self.devices[i].visible = true;
changed = true;
let trainer = self.trainer.status();
let trainer_changed = (trainer != self.last_trainer).then(|| trainer.clone());
self.last_trainer = trainer.clone();
// The scan is switched off for the duration of a connect so that it and
// `find_peripheral` do not fight over the one adapter — and nothing else
// ever turns it back on. A disconnect, or a connect that failed, would
// otherwise leave the list frozen on a snapshot taken before the attempt
// and the rider with no way to find anything but the Scan button
// (FR-1.12, NFR-7).
if should_resume_scan(self.scan_suspended, &trainer) {
self.scan_suspended = false;
self.set_scanning(true);
}
if self.devices[i].visible {
let jitter = (self.rand() * 6.0) as i16 - 3;
let base = self.devices[i].info.rssi;
let next = (base + jitter).clamp(-95, -38);
if next != base {
self.devices[i].info.rssi = next;
changed = true;
let next = self.build(&trainer);
let transitions = state_transitions(&self.published, &next);
let changed = next != self.published;
self.published = next;
PollResult { changed, transitions, trainer_changed }
}
/// Merge the scan snapshot with the trainer's live status.
fn build(&mut self, trainer: &TrainerStatus) -> Vec<DeviceInfo> {
let snapshot = self.scan_rx.borrow().clone();
self.error = snapshot.error.clone();
let mut out: Vec<DeviceInfo> = Vec::with_capacity(snapshot.devices.len() + 1);
for d in &snapshot.devices {
let id = d.address.clone();
if self.forgotten.contains(&id) {
continue;
}
out.push(DeviceInfo {
kind: classify(d),
name: d.label(),
address: d.address.clone(),
rssi: d.rssi.unwrap_or(0),
state: if self.scanning {
ConnectionState::Scanning
} else {
ConnectionState::Idle
},
control_acquired: false,
services: d.services.iter().map(|u| describe_service(*u)).collect(),
remembered: self.remembered.contains(&id),
battery_pct: None,
unlock_expires_in_s: None,
error: None,
id,
});
}
// A connected peripheral usually stops appearing in scan results, and
// the trainer must not vanish from the list the moment it is in use.
if let Some(address) = trainer.address.clone() {
let existing = out.iter().position(|d| d.id == address);
let idx = match existing {
Some(i) => i,
None => {
out.push(DeviceInfo {
id: address.clone(),
name: trainer.name.clone().unwrap_or_else(|| "Trainer".into()),
address,
rssi: 0,
kind: DeviceKind::Trainer,
state: ConnectionState::Idle,
control_acquired: false,
services: vec![describe_service(uuids::FITNESS_MACHINE_SERVICE)],
remembered: true,
battery_pct: None,
unlock_expires_in_s: None,
error: None,
});
out.len() - 1
}
};
let device = &mut out[idx];
device.kind = DeviceKind::Trainer;
device.state = trainer.state.clone();
device.control_acquired = trainer.control_acquired;
device.remembered = true;
device.error = trainer.error.clone().or_else(|| {
trainer
.stale
.then(|| "Connected but sending no data — pedal to wake it".to_string())
});
if let Some(name) = &trainer.name {
device.name = name.clone();
}
}
for d in &mut self.devices {
if let Some((remaining, target, control)) = d.pending.take() {
if remaining <= 1 {
d.info.state = target.clone();
d.info.control_acquired = control;
if target == ConnectionState::Connected && d.info.kind == DeviceKind::Trainer {
// Connected, now go after the FTMS control point.
d.pending =
Some((CONTROL_TICKS, ConnectionState::Controlling, true));
}
transitions.push(d.info.clone());
changed = true;
} else {
d.pending = Some((remaining - 1, target, control));
}
}
}
PollResult { changed, transitions }
// Trainers first, then by signal strength: the thing the rider is
// looking for should not be below an unnamed peripheral.
out.sort_by(|a, b| {
(a.kind != DeviceKind::Trainer)
.cmp(&(b.kind != DeviceKind::Trainer))
.then(b.rssi.cmp(&a.rssi))
.then(a.id.cmp(&b.id))
});
out
}
pub fn list(&self) -> Vec<DeviceInfo> {
self.devices
.iter()
.filter(|d| d.visible && !self.forgotten.contains(&d.info.id))
.map(|d| d.info.clone())
.collect()
self.published.clone()
}
pub fn get(&self, id: &str) -> Option<DeviceInfo> {
self.devices.iter().find(|d| d.info.id == id).map(|d| d.info.clone())
self.published.iter().find(|d| d.id == id).cloned()
}
pub fn connect(&mut self, id: &str) -> Result<DeviceInfo, String> {
let device = self
.devices
.iter_mut()
.find(|d| d.info.id == id)
.get(id)
.ok_or_else(|| format!("no such device: {id}"))?;
if device.info.state == ConnectionState::Controlling {
return Err(format!("{} is already connected", device.info.name));
if device.kind != DeviceKind::Trainer {
return Err(format!(
"{} is not a trainer. Zwift Click support is Phase 3 (REQUIREMENTS.md §5.3).",
device.name
));
}
device.info.error = None;
device.info.state = ConnectionState::Connecting;
device.info.remembered = true;
device.pending = Some((CONNECT_TICKS, ConnectionState::Connected, false));
Ok(device.info.clone())
// A second click on an already-connected trainer must not tear the
// working session down and start over. Connecting takes ~8 s against
// this hardware, which is easily long enough for an impatient rider to
// click again and destroy the connection they were waiting for.
let status = self.trainer.status();
if status.address.as_deref() == Some(device.address.as_str()) {
match status.state {
ConnectionState::Connecting | ConnectionState::Scanning => {
return Err(format!("Already connecting to {}", device.name))
}
ConnectionState::Reconnecting => {
return Err(format!("Reconnecting to {} — hold on.", device.name))
}
ConnectionState::Connected | ConnectionState::Controlling => {
return Err(format!(
"{} is already connected. Disconnect first to start over.",
device.name
))
}
ConnectionState::Idle | ConnectionState::Lost { .. } => {}
}
}
// Our own scan and the client's `find_peripheral` would otherwise fight
// over the one adapter. Suspended, not stopped: `poll` puts it back as
// soon as the trainer is no longer attached (FR-1.12).
self.set_scanning(false);
self.scan_suspended = true;
self.remembered.insert(id.to_string());
self.forgotten.remove(id);
self.trainer
.connect(scan::TrainerSelector::Address(device.address.clone()));
let mut info = device;
info.state = ConnectionState::Connecting;
info.control_acquired = false;
info.error = None;
info.remembered = true;
Ok(info)
}
pub fn disconnect(&mut self, id: &str) -> Result<DeviceInfo, String> {
let device = self
.devices
.iter_mut()
.find(|d| d.info.id == id)
let mut device = self
.get(id)
.ok_or_else(|| format!("no such device: {id}"))?;
device.pending = None;
device.info.control_acquired = false;
device.info.state = if self.scanning { ConnectionState::Scanning } else { ConnectionState::Idle };
Ok(device.info.clone())
if device.kind == DeviceKind::Trainer {
// SAF-2 runs inside the supervisor before the link drops.
self.trainer.disconnect();
}
device.state = ConnectionState::Idle;
device.control_acquired = false;
Ok(device)
}
pub fn forget(&mut self, id: &str) -> Result<(), String> {
let device = self
.devices
.iter_mut()
.find(|d| d.info.id == id)
.ok_or_else(|| format!("no such device: {id}"))?;
device.pending = None;
device.info.remembered = false;
device.info.control_acquired = false;
device.info.state = ConnectionState::Idle;
device.visible = false;
let device = self.get(id).ok_or_else(|| format!("no such device: {id}"))?;
if device.kind == DeviceKind::Trainer && device.control_acquired {
self.trainer.disconnect();
}
self.remembered.remove(id);
self.forgotten.insert(id.to_string());
self.published.retain(|d| d.id != id);
Ok(())
}
/// True once a trainer is connected *and* controllable — the precondition
/// for a real ride (FR-2.1).
pub fn trainer_controllable(&self) -> bool {
self.devices
self.trainer.status().controllable()
}
pub fn trainer_status(&self) -> TrainerStatus {
self.trainer.status()
}
}
/// FR-1.2. FTMS is checked first: the D100 advertises the Zwift custom service
/// too, so "is a Zwift device" is not enough to call something a Click.
fn classify(d: &DiscoveredDevice) -> DeviceKind {
if d.is_fitness_machine() {
return DeviceKind::Trainer;
}
if d.services.contains(&HEART_RATE_SERVICE) {
return DeviceKind::HeartRate;
}
if d.services.contains(&ZWIFT_SERVICE) || d.is_zwift_device() {
// Splitting left pod from right needs the Zwift manufacturer-data type
// byte, which is Phase 3 and unverified against this hardware. Guessing
// would put a wrong label on the connection screen, so it stays Unknown
// and the service UUID is listed instead.
return DeviceKind::Unknown;
}
DeviceKind::Unknown
}
fn describe_service(uuid: Uuid) -> String {
match uuids::well_known_name(uuid) {
Some(name) => format!("{uuid} ({name})"),
None if uuid == ZWIFT_SERVICE => format!("{uuid} (Zwift custom)"),
None => uuid.to_string(),
}
}
/// May a scan we suspended for a connect be switched back on?
///
/// FR-1.12. Split out from [`DeviceRegistry::poll`] so the rule is checkable
/// without a radio. Only a suspension of *ours* is resumed — a rider who
/// pressed Stop scan meant it.
fn should_resume_scan(suspended: bool, trainer: &TrainerStatus) -> bool {
suspended && !trainer.is_attached()
}
/// Devices whose connection state or control acquisition changed (FR-1.7).
fn state_transitions(before: &[DeviceInfo], after: &[DeviceInfo]) -> Vec<DeviceInfo> {
after
.iter()
.any(|d| d.info.kind == DeviceKind::Trainer && d.info.control_acquired)
.filter(|d| match before.iter().find(|p| p.id == d.id) {
None => d.state != ConnectionState::Scanning && d.state != ConnectionState::Idle,
Some(prev) => prev.state != d.state || prev.control_acquired != d.control_acquired,
})
.cloned()
.collect()
}
/// Drive the radio. Runs for the life of the process; a failure to get an
/// adapter is reported through the snapshot rather than killing the task, so
/// plugging a dongle in later recovers on its own (NFR-4).
async fn scan_loop(mut on: watch::Receiver<bool>, tx: watch::Sender<ScanSnapshot>) {
let mut generation = 0u64;
loop {
if !*on.borrow() {
// Wait to be switched on rather than spinning.
if on.changed().await.is_err() {
return;
}
continue;
}
let adapter = match scan::default_adapter().await {
Ok(a) => a,
Err(e) => {
tracing::warn!(error = %e, "no Bluetooth adapter");
generation += 1;
let _ = tx.send(ScanSnapshot {
devices: Vec::new(),
error: Some(format!("{e}. Check the radio is on and BlueZ is running.")),
generation,
});
tokio::time::sleep(Duration::from_secs(2)).await;
continue;
}
};
// FR-1.1 lists every peripheral, not only fitness machines: a trainer
// is not obliged to advertise FTMS, and the rider needs to see what is
// in the room to know the scan is working at all.
let result = scan::scan(&adapter, SCAN_WINDOW, ScanKind::All).await;
generation += 1;
let snapshot = match result {
Ok(devices) => ScanSnapshot { devices, error: None, generation },
Err(e) => {
tracing::warn!(error = %e, "scan failed");
ScanSnapshot {
devices: Vec::new(),
error: Some(e.to_string()),
generation,
}
}
};
let _ = tx.send(snapshot);
tokio::time::sleep(IDLE_POLL).await;
}
}
fn device(
id: &str,
name: &str,
address: &str,
rssi: i16,
kind: DeviceKind,
services: &[&str],
appears_after: u32,
) -> Simulated {
Simulated {
info: DeviceInfo {
#[cfg(test)]
mod tests {
use super::*;
fn info(id: &str, state: ConnectionState, control: bool) -> DeviceInfo {
DeviceInfo {
id: id.into(),
name: name.into(),
address: address.into(),
rssi,
kind,
state: ConnectionState::Idle,
control_acquired: false,
services: services.iter().map(|s| s.to_string()).collect(),
name: id.into(),
address: id.into(),
rssi: -50,
kind: DeviceKind::Trainer,
state,
control_acquired: control,
services: Vec::new(),
remembered: false,
battery_pct: match kind {
DeviceKind::ClickLeft => Some(78),
DeviceKind::ClickRight => Some(64),
DeviceKind::HeartRate => Some(91),
_ => None,
},
unlock_expires_in_s: match kind {
DeviceKind::ClickLeft => Some(0),
DeviceKind::ClickRight => Some(41_400),
_ => None,
},
battery_pct: None,
unlock_expires_in_s: None,
error: None,
},
appears_after,
pending: None,
visible: false,
}
}
#[test]
fn acquiring_control_is_a_transition_even_though_the_state_is_unchanged() {
// FR-9.3: Connected → Controlling *and* Connected-with-control are both
// events the UI must see.
let before = vec![info("t", ConnectionState::Connected, false)];
let after = vec![info("t", ConnectionState::Connected, true)];
let t = state_transitions(&before, &after);
assert_eq!(t.len(), 1);
assert!(t[0].control_acquired);
}
#[test]
fn a_device_merely_appearing_in_a_scan_is_not_a_transition() {
let after = vec![info("t", ConnectionState::Scanning, false)];
assert!(state_transitions(&[], &after).is_empty());
let after = vec![info("t", ConnectionState::Idle, false)];
assert!(state_transitions(&[], &after).is_empty());
}
#[test]
fn a_device_that_appears_already_connected_is_a_transition() {
let after = vec![info("t", ConnectionState::Controlling, true)];
assert_eq!(state_transitions(&[], &after).len(), 1);
}
#[test]
fn a_lost_link_is_reported() {
let before = vec![info("t", ConnectionState::Controlling, true)];
let after = vec![info("t", ConnectionState::Lost { reason: "gone".into() }, false)];
let t = state_transitions(&before, &after);
assert_eq!(t.len(), 1);
assert!(!t[0].control_acquired);
}
#[test]
fn an_unchanged_list_produces_no_transitions() {
let list = vec![info("t", ConnectionState::Controlling, true)];
assert!(state_transitions(&list, &list).is_empty());
}
#[test]
fn a_scan_suspended_for_a_connect_comes_back_when_the_connect_is_over() {
// FR-1.12. Connecting switches the scan off so it does not fight
// `find_peripheral` over the one adapter, and before this nothing ever
// switched it back on: a disconnect or a failed connect left the device
// list frozen on a snapshot taken before the attempt.
let idle = TrainerStatus::default();
let lost = TrainerStatus {
state: ConnectionState::Lost { reason: "gone".into() },
..TrainerStatus::default()
};
assert!(should_resume_scan(true, &idle));
assert!(should_resume_scan(true, &lost));
// Still in progress, or in use: leave the adapter alone.
let connecting = TrainerStatus {
state: ConnectionState::Connecting,
..TrainerStatus::default()
};
let reconnecting = TrainerStatus {
state: ConnectionState::Reconnecting,
..TrainerStatus::default()
};
let riding = TrainerStatus {
state: ConnectionState::Controlling,
control_acquired: true,
..TrainerStatus::default()
};
assert!(!should_resume_scan(true, &connecting));
assert!(!should_resume_scan(true, &reconnecting));
assert!(!should_resume_scan(true, &riding));
// The rider pressed Stop scan. That is not ours to undo.
assert!(!should_resume_scan(false, &idle));
}
#[test]
fn well_known_services_are_named_and_zwift_is_recognised() {
let ftms = describe_service(uuids::FITNESS_MACHINE_SERVICE);
assert!(ftms.contains("Fitness Machine"), "{ftms}");
let zwift = describe_service(ZWIFT_SERVICE);
assert!(zwift.contains("Zwift"), "{zwift}");
}
}
/// The mock environment. Timings are in registry ticks (2 Hz), so the trainer
/// takes ~2 s to appear and the pods ~46 s — long enough that the "wake it by
/// pedalling" prompt (FR-1.8) is actually visible.
fn catalogue() -> Vec<Simulated> {
vec![
device(
"d100-1",
"Van Rysel D100",
"E4:2B:11:9A:03:7C",
-54,
DeviceKind::Trainer,
&["0x1826 Fitness Machine", "0x180A Device Information"],
4,
),
device(
"click-l",
"Zwift Click (left)",
"C0:1A:77:12:4E:01",
-63,
DeviceKind::ClickLeft,
&["00000001-19CA-4651-86E5-FA29DCDD09D1"],
9,
),
device(
"click-r",
"Zwift Click (right)",
"C0:1A:77:12:4E:02",
-61,
DeviceKind::ClickRight,
&["00000001-19CA-4651-86E5-FA29DCDD09D1"],
11,
),
device(
"hrm-1",
"Wahoo TICKR",
"D9:44:0B:31:88:2A",
-71,
DeviceKind::HeartRate,
&["0x180D Heart Rate"],
14,
),
device(
"unknown-1",
"(unnamed peripheral)",
"7F:22:C4:08:19:E3",
-88,
DeviceKind::Unknown,
&[],
17,
),
]
}
+11 -1
View File
@@ -9,6 +9,7 @@ use serde::Serialize;
use crate::devices::DeviceInfo;
use crate::profile_view::ProfileView;
use crate::trainer::TrainerStatus;
/// `RideSnapshot`, pushed at [`crate::engine::TICK_HZ`].
pub const RIDE_SNAPSHOT: &str = "ride://snapshot";
@@ -24,6 +25,11 @@ pub const DEVICE_CONNECTION: &str = "devices://connection";
pub const APP_NOTICE: &str = "app://notice";
/// Acknowledgement that an input registered, so the UI can flash (FR-9.9).
pub const INPUT_ACK: &str = "app://input-ack";
/// A Zwift Click button edge. The webview routes these to the same intents as
/// the equivalent keypress, so controller and keyboard cannot drift apart.
pub const CONTROLLER_INPUT: &str = "controller://input";
/// Controller link state and battery.
pub const CONTROLLER_STATUS: &str = "controller://status";
/// Ride lifecycle, mirroring `bikecontrol_core::session::RideStatus` but
/// serialisable across the IPC boundary.
@@ -52,8 +58,12 @@ pub struct RideState {
pub lap: u32,
pub laps: Vec<LapSummary>,
pub profile: Option<ProfileView>,
/// Which backend is driving the ride `"mock"` until `crates/ble` lands.
/// Which backend is driving the ride: `"ftms"` (the real trainer) or
/// `"mock"` (the synthetic rider, only reachable via `BIKECONTROL_DEMO`).
pub source: &'static str,
/// Trainer link, so the ride screen can say when the numbers stopped being
/// real rather than quietly showing zeros (FR-1.8, FR-9.3).
pub trainer: TrainerStatus,
}
#[derive(Debug, Clone, Copy, Serialize)]
+19 -5
View File
@@ -7,14 +7,17 @@
pub mod backend;
pub mod commands;
pub mod controller;
pub mod derive;
pub mod devices;
pub mod events;
#[cfg(feature = "mock-ride")]
pub mod mock;
pub mod profile_view;
pub mod samples;
pub mod session_backend;
pub mod state;
pub mod trainer;
use tauri::{Manager, RunEvent, WindowEvent};
@@ -68,6 +71,10 @@ pub fn run() {
commands::disconnect_device,
commands::forget_device,
commands::trainer_controllable,
// controller
commands::controller_status,
commands::connect_controller,
commands::disconnect_controller,
])
.setup(|app| {
let handle = app.handle().clone();
@@ -75,6 +82,7 @@ pub fn run() {
handle.state::<AppState>().lock().devices.start_scan();
state::spawn_ride_loop(handle.clone());
state::spawn_device_loop(handle.clone());
state::spawn_controller_loop(handle.clone());
// `BIKECONTROL_DEMO=1` opens straight onto a running ride with the
// bundled GPX loaded. Purely a development convenience — it makes
// the ride screen reviewable without clicking through first.
@@ -88,12 +96,18 @@ pub fn run() {
.build(tauri::generate_context!())
.expect("failed to start BikeControl")
.run(|app, event| {
// SAF-2 — on any exit path, hand the trainer back at zero load.
if let RunEvent::ExitRequested { .. } = &event {
state::release_trainer(app);
// SAF-2 / SAF-9 — on any exit path, hand the trainer back at zero
// load and close every link the app owns. `shutdown_devices` blocks
// until the reset sequence has actually been written; a
// fire-and-forget send would race the process teardown and leave the
// rider on a loaded trainer. It is idempotent, which matters because
// one quit delivers several of these events.
match &event {
RunEvent::ExitRequested { .. } | RunEvent::Exit => state::shutdown_devices(app),
RunEvent::WindowEvent { event: WindowEvent::Destroyed, .. } => {
state::shutdown_devices(app)
}
if let RunEvent::WindowEvent { event: WindowEvent::Destroyed, .. } = &event {
state::release_trainer(app);
_ => {}
}
});
}
+9 -3
View File
@@ -1,5 +1,10 @@
//! A synthetic rider, so the UI can be built and judged before `crates/ble`
//! and `crates/core` are finished.
//! A synthetic rider, so the UI can be built and judged with no trainer on the
//! desk.
//!
//! No longer the default: the app rides `RideSession` on real FTMS telemetry
//! unless `BIKECONTROL_DEMO=1` or `BIKECONTROL_MOCK=1` selects this instead.
//! It is compiled only under the `mock-ride` feature, so a build made with
//! `--no-default-features` cannot show fake data at all.
//!
//! It fabricates plausible power and cadence, then runs them through the §5.7
//! physics equations to get virtual speed, distance and elevation gain. The
@@ -197,7 +202,8 @@ impl RideBackend for MockBackend {
let snapshot = RideSnapshot {
elapsed_ms: self.elapsed_ms,
telemetry,
virtual_speed_kph: self.speed_ms * 3.6,
// Not running means not moving, and the readout must agree.
virtual_speed_kph: if running { self.speed_ms * 3.6 } else { 0.0 },
virtual_distance_m: self.distance_m,
gradient_pct,
elevation_gain_m: self.elevation_gain_m,
+235 -12
View File
@@ -1,15 +1,22 @@
//! The real backend: `bikecontrol_core::RideSession` driven by trainer
//! telemetry.
//!
//! Compiled only under `--features real-session`, because
//! `RideSession::tick`/`snapshot` are still `todo!()` and would panic on the
//! first tick. Enabling the feature (and disabling `mock-ride`) is the whole
//! swap — nothing above [`crate::backend::RideBackend`] changes, and the
//! frontend does not change at all.
#![cfg(feature = "real-session")]
//! This is the app's default data source. It holds the latest decoded Indoor
//! Bike Data sample — published by [`crate::trainer`] from `bikecontrol_ble`'s
//! telemetry stream — and feeds it to the ride engine once per tick.
//!
//! The engine, not this module, owns the physics: FR-7.1/7.5 say virtual speed
//! and distance are computed from *power*, not from the trainer's own speed
//! reading. That is what makes the ride behave correctly when the trainer's
//! speed is wrong, or absent, or (as on the D100) reported in units nobody has
//! confirmed.
//!
//! When no trainer is attached the watch channel carries `Telemetry::default()`
//! — zero power — so the ride coasts to a stop instead of freezing on the last
//! real sample.
use bikecontrol_core::session::{RideSession, SessionEvent};
use bikecontrol_core::types::{ControlTarget, RideSnapshot, Telemetry};
use bikecontrol_core::types::{RideSnapshot, Telemetry};
use tokio::sync::watch;
use crate::backend::{RideBackend, RideInputs, Tick};
@@ -17,9 +24,12 @@ use crate::events::RideStatus;
pub struct SessionBackend {
session: RideSession,
/// Latest decoded Indoor Bike Data, published by `bikecontrol_ble`.
/// Latest decoded Indoor Bike Data, published by [`crate::trainer`].
telemetry: watch::Receiver<Telemetry>,
last_snapshot: Option<RideSnapshot>,
/// Set once a profile has been handed to the session, so a profile swap is
/// noticed but the same profile is not reloaded every tick.
loaded_profile: Option<usize>,
}
impl SessionBackend {
@@ -28,6 +38,7 @@ impl SessionBackend {
session: RideSession::new(inputs.rider, inputs.limits),
telemetry,
last_snapshot: None,
loaded_profile: None,
}
}
}
@@ -39,15 +50,48 @@ impl RideBackend for SessionBackend {
fn reset(&mut self) {
self.session = RideSession::new(self.session.config, self.session.limits);
self.last_snapshot = None;
self.loaded_profile = None;
}
fn tick(&mut self, dt_s: f32, inputs: &RideInputs) -> Tick {
// Rider intent lives in `RideInputs` (the Tauri commands mutate it);
// the session is told about it each tick rather than being driven
// directly, so there is one source of truth for what the rider asked
// for regardless of which backend is running.
self.session.config = inputs.rider;
self.session.limits = inputs.limits;
self.session.mode = inputs.mode;
if let Some(profile) = inputs.profile.as_deref() {
if self.session.profile().is_none() {
self.session.set_resistance(inputs.resistance_level);
self.session.set_erg_power(inputs.power_target_w);
match inputs.profile.as_deref() {
Some(profile) => {
// `Arc` identity, not contents: reloading resets the session's
// position, which must not happen every tick.
let id = inputs.profile.as_ref().map(|p| std::sync::Arc::as_ptr(p) as usize);
if self.loaded_profile != id {
self.session.load_profile(profile.clone());
self.session.mode = inputs.mode;
self.loaded_profile = id;
}
}
None => self.loaded_profile = None,
}
// FR-4.2: in ManualGrade the rider's absolute setting *is* the gradient;
// elsewhere the nudge is a trim on top of the profile.
let offset = match inputs.mode {
bikecontrol_core::types::ControlMode::ManualGrade => {
inputs.manual_gradient_pct + inputs.gradient_offset_pct
}
_ => inputs.gradient_offset_pct,
};
self.session.reset_gradient_offset();
self.session.nudge_gradient(offset);
self.session.gearing.set_gear(inputs.gear);
match inputs.status {
RideStatus::Running => self.session.start(),
RideStatus::Paused => self.session.pause(),
@@ -64,12 +108,191 @@ impl RideBackend for SessionBackend {
SessionEvent::ProfileFinished | SessionEvent::Lap { .. } => {}
}
}
let snapshot = snapshot
let mut snapshot = snapshot
.or(self.last_snapshot)
.unwrap_or_else(|| self.session.snapshot(telemetry));
// A ride that is not running is a rider who is not moving, and the
// screen must say so. The engine deliberately keeps the physics state
// across a pause so the ride resumes where it stopped — but reporting
// that held velocity reads as "you are doing 38 km/h" to someone
// standing still, which is the one thing a readout must never do.
// Distance and elapsed already freeze; speed has to go to zero.
if inputs.status != RideStatus::Running {
snapshot.virtual_speed_kph = 0.0;
}
self.last_snapshot = Some(snapshot);
let _: Option<ControlTarget> = command;
Tick { snapshot, command }
}
}
#[cfg(test)]
mod tests {
use super::*;
use bikecontrol_core::types::{ControlMode, ControlTarget};
fn running(mode: ControlMode) -> RideInputs {
RideInputs { status: RideStatus::Running, mode, ..RideInputs::default() }
}
#[test]
fn real_power_drives_the_ride_forward() {
let (tx, rx) = watch::channel(Telemetry::default());
let inputs = running(ControlMode::ManualGrade);
let mut backend = SessionBackend::new(&inputs, rx);
assert_eq!(backend.source(), "ftms");
// No power: nothing moves.
for _ in 0..8 {
backend.tick(0.25, &inputs);
}
assert_eq!(backend.tick(0.25, &inputs).snapshot.virtual_distance_m, 0.0);
// 200 W from the trainer: the engine accelerates.
let _ = tx.send(Telemetry { power_w: Some(200), ..Telemetry::default() });
for _ in 0..40 {
backend.tick(0.25, &inputs);
}
let snapshot = backend.tick(0.25, &inputs).snapshot;
assert!(snapshot.virtual_speed_kph > 5.0, "{snapshot:?}");
assert!(snapshot.virtual_distance_m > 10.0, "{snapshot:?}");
assert_eq!(snapshot.telemetry.power_w, Some(200));
}
#[test]
fn losing_the_trainer_coasts_to_a_stop_rather_than_freezing() {
let (tx, rx) = watch::channel(Telemetry { power_w: Some(250), ..Telemetry::default() });
let inputs = running(ControlMode::ManualGrade);
let mut backend = SessionBackend::new(&inputs, rx);
for _ in 0..60 {
backend.tick(0.25, &inputs);
}
let moving = backend.tick(0.25, &inputs).snapshot.virtual_speed_kph;
assert!(moving > 5.0);
// The supervisor zeroes telemetry when the link drops.
let _ = tx.send(Telemetry::default());
for _ in 0..400 {
backend.tick(0.25, &inputs);
}
let stopped = backend.tick(0.25, &inputs).snapshot;
assert!(stopped.virtual_speed_kph < moving, "speed must decay, not hold");
assert_eq!(stopped.telemetry.power_w, None);
}
#[test]
fn the_manual_gradient_reaches_the_trainer() {
let (_tx, rx) = watch::channel(Telemetry::default());
let mut inputs = running(ControlMode::ManualGrade);
inputs.manual_gradient_pct = 5.0;
let mut backend = SessionBackend::new(&inputs, rx);
let tick = backend.tick(0.25, &inputs);
assert_eq!(tick.command, Some(ControlTarget::Gradient { percent: 5.0 }));
assert_eq!(tick.snapshot.gradient_pct, 5.0);
}
#[test]
fn a_trim_stacks_on_the_manual_gradient_and_is_not_applied_twice() {
let (_tx, rx) = watch::channel(Telemetry::default());
let mut inputs = running(ControlMode::ManualGrade);
inputs.manual_gradient_pct = 3.0;
inputs.gradient_offset_pct = 1.5;
let mut backend = SessionBackend::new(&inputs, rx);
for _ in 0..10 {
backend.tick(0.25, &inputs);
}
// Would be 3 + 1.5 * 10 if the nudge accumulated across ticks.
assert_eq!(backend.tick(0.25, &inputs).snapshot.gradient_pct, 4.5);
}
#[test]
fn erg_and_resistance_targets_come_from_rider_intent() {
let (_tx, rx) = watch::channel(Telemetry::default());
let mut inputs = running(ControlMode::Erg);
inputs.power_target_w = 275;
let mut backend = SessionBackend::new(&inputs, rx);
assert_eq!(
backend.tick(0.25, &inputs).command,
Some(ControlTarget::Power { watts: 275 })
);
let mut inputs = running(ControlMode::Resistance);
inputs.resistance_level = 42;
assert_eq!(
backend.tick(0.25, &inputs).command,
Some(ControlTarget::Resistance { level: 42 })
);
}
#[test]
fn targets_are_clamped_before_they_leave() {
// SAF-3 is enforced by the engine; assert the backend does not bypass it.
let (_tx, rx) = watch::channel(Telemetry::default());
let mut inputs = running(ControlMode::ManualGrade);
inputs.manual_gradient_pct = 400.0;
let mut backend = SessionBackend::new(&inputs, rx);
let tick = backend.tick(0.25, &inputs);
assert_eq!(
tick.command,
Some(ControlTarget::Gradient { percent: inputs.limits.max_gradient_pct })
);
}
#[test]
fn a_paused_ride_commands_nothing() {
// SAF-1: the last target stands; a pause must not push a new load.
let (_tx, rx) = watch::channel(Telemetry { power_w: Some(200), ..Telemetry::default() });
let mut inputs = running(ControlMode::ManualGrade);
let mut backend = SessionBackend::new(&inputs, rx);
backend.tick(0.25, &inputs);
inputs.status = RideStatus::Paused;
inputs.manual_gradient_pct = 9.0;
assert_eq!(backend.tick(0.25, &inputs).command, None);
}
#[test]
fn a_ride_that_is_not_running_reports_no_speed() {
// Regression: elapsed and distance froze on pause but speed held its
// last value, so a stationary rider was shown 38 km/h indefinitely.
let (tx, rx) = watch::channel(Telemetry::default());
let mut inputs = running(ControlMode::ManualGrade);
let mut backend = SessionBackend::new(&inputs, rx);
let _ = tx.send(Telemetry { power_w: Some(250), ..Telemetry::default() });
for _ in 0..60 {
backend.tick(0.25, &inputs);
}
let moving = backend.tick(0.25, &inputs).snapshot;
assert!(moving.virtual_speed_kph > 10.0, "{moving:?}");
for status in [RideStatus::Paused, RideStatus::Finished, RideStatus::Idle] {
inputs.status = status;
let stopped = backend.tick(0.25, &inputs).snapshot;
assert_eq!(stopped.virtual_speed_kph, 0.0, "{status:?} still showed speed");
// Distance must not be thrown away — the ride resumes where it was.
assert!(stopped.virtual_distance_m >= moving.virtual_distance_m);
}
// And resuming picks the ride back up rather than starting from rest.
inputs.status = RideStatus::Running;
let resumed = backend.tick(0.25, &inputs).snapshot;
assert!(resumed.virtual_speed_kph > 10.0, "{resumed:?}");
}
#[test]
fn reset_returns_to_the_start_line() {
let (tx, rx) = watch::channel(Telemetry::default());
let inputs = running(ControlMode::ManualGrade);
let mut backend = SessionBackend::new(&inputs, rx);
let _ = tx.send(Telemetry { power_w: Some(300), ..Telemetry::default() });
for _ in 0..40 {
backend.tick(0.25, &inputs);
}
assert!(backend.tick(0.25, &inputs).snapshot.virtual_distance_m > 0.0);
backend.reset();
let snapshot = backend.tick(0.25, &inputs).snapshot;
// One tick from standstill covers centimetres, not the metres just ridden.
assert!(snapshot.virtual_distance_m < 1.0, "{snapshot:?}");
assert_eq!(snapshot.elapsed_ms, 250);
}
}
+218 -21
View File
@@ -8,7 +8,7 @@ use std::sync::{Arc, Mutex};
use std::time::Duration;
use bikecontrol_core::profile::Profile;
use bikecontrol_core::types::{ControlTarget, RideSnapshot};
use bikecontrol_core::types::{ConnectionState, ControlTarget, RideSnapshot};
use tauri::{AppHandle, Emitter, Manager};
use crate::backend::{RideBackend, RideInputs};
@@ -18,8 +18,9 @@ use crate::events::{
ConnectionEvent, DeviceList, InputAck, LapSummary, Notice, RideState, RideStatus,
};
use crate::derive::{Derived, Deriver, RideFrame};
use crate::mock::MockBackend;
use crate::profile_view::{ProfileGeometry, ProfileView};
use crate::session_backend::SessionBackend;
use crate::trainer::{TrainerHandle, TrainerStatus};
/// Snapshot push rate. FTMS notifies at 14 Hz (NFR-2); we publish at the top
/// of that range and the frontend interpolates nothing.
@@ -32,6 +33,8 @@ pub struct Inner {
pub inputs: RideInputs,
pub backend: Box<dyn RideBackend>,
pub devices: DeviceRegistry,
pub trainer: TrainerHandle,
pub controller: crate::controller::ControllerHandle,
pub profile_view: Option<ProfileView>,
/// Precomputed route geometry, kept Rust-side so the per-tick elevation and
/// ascent-remaining lookups are a binary search rather than a scan.
@@ -47,12 +50,49 @@ pub struct Inner {
lap_power_n: u64,
}
/// Choose the ride's data source.
///
/// **There is no fallback.** A missing, sleeping or uncontrollable trainer
/// yields zeros, not invented numbers: the ride engine reads
/// `Telemetry::default()` and the screen shows a rider who is not pedalling,
/// which is the truth. Substituting a synthetic rider when the hardware is
/// absent would mean a rider could complete a session and only discover
/// afterwards that none of it happened.
///
/// The synthetic rider is therefore opt-in, deliberately, and only from
/// outside the app: `BIKECONTROL_DEMO=1` (which also loads a route and starts
/// riding) or `BIKECONTROL_MOCK=1`. It exists only under the `mock-ride`
/// feature, so a build made with `--no-default-features` is incapable of
/// showing fake data at all. Whenever it is on, `RideState::source` reports
/// `"mock"` and the ride screen carries a banner that cannot be missed.
fn build_backend(inputs: &RideInputs, trainer: &TrainerHandle) -> Box<dyn RideBackend> {
#[cfg(feature = "mock-ride")]
if std::env::var_os("BIKECONTROL_DEMO").is_some()
|| std::env::var_os("BIKECONTROL_MOCK").is_some()
{
tracing::warn!(
source = "mock",
"BIKECONTROL_DEMO/MOCK is set — this ride is a SIMULATION. Power, speed and \
distance are fabricated and nothing is being read from a trainer."
);
return Box::new(crate::mock::MockBackend::default());
}
tracing::info!(
source = "ftms",
"ride data source is the trainer; with no trainer attached the ride reads zero"
);
Box::new(SessionBackend::new(inputs, trainer.telemetry()))
}
impl Inner {
fn new() -> Self {
fn new(trainer: TrainerHandle, controller: crate::controller::ControllerHandle) -> Self {
let inputs = RideInputs::default();
Self {
inputs: RideInputs::default(),
backend: Box::new(MockBackend::default()),
devices: DeviceRegistry::new(),
backend: build_backend(&inputs, &trainer),
devices: DeviceRegistry::new(trainer.clone()),
inputs,
trainer,
controller,
profile_view: None,
geometry: None,
deriver: Deriver::default(),
@@ -80,6 +120,7 @@ impl Inner {
laps: self.laps.clone(),
profile: self.profile_view.clone(),
source: self.backend.source(),
trainer: self.trainer.status(),
}
}
@@ -148,9 +189,13 @@ impl Inner {
}
}
let profile = self.inputs.profile.clone();
let derived =
self.deriver
.update(snapshot, running, profile.as_deref(), self.geometry.as_ref());
let derived = self.deriver.update(
snapshot,
running,
self.inputs.rider.rider_kg,
profile.as_deref(),
self.geometry.as_ref(),
);
self.last_derived = Some(derived);
derived
}
@@ -167,7 +212,21 @@ impl Default for AppState {
impl AppState {
pub fn new() -> Self {
Self(Arc::new(Mutex::new(Inner::new())))
let limits = RideInputs::default().limits;
let trainer = TrainerHandle::spawn(crate::trainer::app_config(limits));
let controller = crate::controller::ControllerHandle::spawn();
Self(Arc::new(Mutex::new(Inner::new(trainer, controller))))
}
/// The trainer supervisor handle, for callers outside the lock (SAF-2 at
/// exit must not hold the mutex while it waits on the radio).
pub fn trainer(&self) -> TrainerHandle {
self.lock().trainer.clone()
}
/// The controller supervisor handle.
pub fn controller(&self) -> crate::controller::ControllerHandle {
self.lock().controller.clone()
}
/// Panics are impossible to recover from here, and a poisoned lock means
@@ -203,6 +262,60 @@ pub fn ack(app: &AppHandle, action: &str, detail: Option<String>) {
let _ = app.emit(events::INPUT_ACK, InputAck { action: action.into(), detail });
}
/// Forward controller button edges and link status to the webview.
///
/// The backend deliberately does **not** decide what a button means. It reports
/// "`plus` was pressed"; the webview routes that to the same intent as the
/// matching keypress. One input map, not two that can drift (§4.3).
pub fn spawn_controller_loop(app: AppHandle) {
tauri::async_runtime::spawn(async move {
let controller = app.state::<AppState>().controller();
let mut inputs = controller.inputs();
let mut status = controller.status_watch();
loop {
tokio::select! {
input = inputs.recv() => match input {
Ok(input) => {
// The paddles shift the virtual gear (FR-4.1, OQ-1).
// Done here rather than in the webview so gearing keeps
// working with the window unfocused or minimised.
if input.pressed {
let delta = match input.button {
"plus" => 1i32,
"minus" => -1,
_ => 0,
};
if delta != 0 {
let state = app.state::<AppState>();
let mut inner = state.lock();
let next = (inner.inputs.gear as i32 + delta).max(1);
inner.inputs.gear = next as usize;
drop(inner);
emit_ride_state(&app);
}
}
let _ = app.emit(events::CONTROLLER_INPUT, input);
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
// Dropping a *release* edge would latch a button on, so
// this is worth saying out loud rather than swallowing.
tracing::warn!("controller: webview missed {n} button edge(s)");
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
},
changed = status.changed() => {
if changed.is_err() {
return;
}
let payload = status.borrow_and_update().clone();
let _ = app.emit(events::CONTROLLER_STATUS, payload);
}
}
}
});
}
/// The ride loop. One tick: advance the backend, publish the snapshot, and
/// transmit the (already clamped) target to the trainer.
pub fn spawn_ride_loop(app: AppHandle) {
@@ -210,6 +323,7 @@ pub fn spawn_ride_loop(app: AppHandle) {
let mut interval = tokio::time::interval(Duration::from_millis(TICK_MS));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let dt_s = TICK_MS as f32 / 1000.0;
let mut n: u64 = 0;
loop {
interval.tick().await;
let (frame, command) = {
@@ -221,6 +335,25 @@ pub fn spawn_ride_loop(app: AppHandle) {
let derived = inner.absorb(&tick.snapshot);
(RideFrame { snapshot: tick.snapshot, derived }, tick.command)
};
n += 1;
if n % TICK_HZ == 0 {
let s = &frame.snapshot;
tracing::debug!(
elapsed_ms = s.elapsed_ms,
speed_kph = s.virtual_speed_kph,
distance_m = s.virtual_distance_m,
gradient = s.gradient_pct,
power = ?s.telemetry.power_w,
// The trainer's OWN speed, straight from Indoor Bike Data,
// alongside the speed our physics computed. Divergence
// between the two is the fastest way to tell a physics
// problem from a telemetry problem.
trainer_kph = ?s.telemetry.speed_kph,
cadence = ?s.telemetry.cadence_rpm,
?command,
"ride tick"
);
}
let _ = app.emit(events::RIDE_SNAPSHOT, frame);
if let Some(target) = command {
transmit(&app, target);
@@ -229,11 +362,16 @@ pub fn spawn_ride_loop(app: AppHandle) {
});
}
/// Where the FTMS control-point write will go. Until `crates/ble` exists this
/// only logs — but every target already passed `SafetyLimits::clamp` before it
/// got here (SAF-3), so wiring the real write is a one-line change.
fn transmit(_app: &AppHandle, target: ControlTarget) {
tracing::debug!(?target, "control target (no trainer attached — mock backend)");
/// Push a target to the trainer's FTMS control point.
///
/// Every target has already passed `SafetyLimits::clamp` in the ride engine,
/// and `bikecontrol_ble` clamps again against the trainer's own reported ranges
/// at the point of transmission (SAF-3, FR-2.6). Non-blocking: the BLE layer
/// rate-limits to 4 Hz and coalesces, so the ride loop never waits on a radio.
fn transmit(app: &AppHandle, target: ControlTarget) {
let state = app.state::<AppState>();
let trainer = state.lock().trainer.clone();
trainer.set_target(target);
}
/// Load the bundled GPX and start riding it. Development only — see the
@@ -260,16 +398,41 @@ pub fn start_demo(app: &AppHandle) {
inner.inputs.status = RideStatus::Running;
}
/// SAF-2: never leave the trainer loaded. Called on ride end and on app exit.
/// SAF-2 at the end of a ride: zero gradient / minimum resistance, link kept so
/// the next ride does not have to reconnect.
pub fn release_trainer(app: &AppHandle) {
let state = app.state::<AppState>();
let limits = state.lock().inputs.limits;
let safe = limits.clamp(ControlTarget::Gradient { percent: 0.0 });
tracing::info!(?safe, "releasing trainer (SAF-2)");
transmit(app, safe);
let (trainer, limits) = {
let inner = state.lock();
(inner.trainer.clone(), inner.inputs.limits)
};
tracing::info!("releasing trainer to zero load (SAF-2)");
trainer.release(limits);
}
/// The scan loop: advances the device mock and pushes the list when it changes.
/// Close every BLE link the app owns, on the way out.
///
/// The trainer first and with the full SAF-2 reset — zero gradient, minimum
/// resistance, `Reset`, `Stop` — then the controller, which needs no reset but
/// does need disconnecting (SAF-9): a link the process merely abandons can
/// leave the peripheral held and unreachable on the next launch.
///
/// Blocks. That is the point: a fire-and-forget send races the process exit and
/// leaves the trainer loaded, which is exactly the failure SAF-2 exists to
/// prevent. Both supervisors abandon whatever connect or reconnect is in flight
/// rather than finish it, so the wait is bounded by their own budgets and not by
/// the radio (FR-1.10, NFR-9). The lock is released before waiting so the ride
/// loop can finish.
pub fn shutdown_devices(app: &AppHandle) {
let Some(state) = app.try_state::<AppState>() else { return };
let (trainer, controller) = (state.trainer(), state.controller());
trainer.shutdown_blocking();
controller.shutdown_blocking();
}
/// The scan loop: refreshes the device list from the radio and pushes it when
/// it changes, and turns trainer state changes into notices the rider can act
/// on (FR-1.7, FR-1.8, FR-9.2).
pub fn spawn_device_loop(app: AppHandle) {
tauri::async_runtime::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_millis(SCAN_TICK_MS));
@@ -292,9 +455,43 @@ pub fn spawn_device_loop(app: AppHandle) {
},
);
}
if let Some(status) = result.trainer_changed {
if let Some(notice) = trainer_notice(&status) {
notify(&app, notice);
}
emit_ride_state(&app);
}
if result.changed {
emit_devices(&app);
}
}
});
}
/// Say what is wrong, in words, whenever the trainer link changes (FR-1.8,
/// FR-9.4). Silence is the one thing that is not allowed: a rider staring at
/// zeros must be told whether the trainer is missing, asleep or refusing
/// control.
fn trainer_notice(status: &TrainerStatus) -> Option<Notice> {
let name = status.name.clone().unwrap_or_else(|| "Trainer".into());
match &status.state {
ConnectionState::Connecting => Some(Notice::info(format!("Connecting to {name}"))),
ConnectionState::Connected => Some(Notice::warn(format!(
"{name} connected but control is not acquired — it will not respond to targets yet"
))),
ConnectionState::Controlling if status.stale => Some(Notice::warn(format!(
"{name} is connected but sending no data — turn the cranks to wake it"
))),
ConnectionState::Controlling => match &status.error {
Some(error) => Some(Notice::error(format!("{name}: {error}"))),
None => Some(Notice::info(format!("{name} connected — control acquired"))),
},
ConnectionState::Reconnecting => Some(Notice::warn(format!(
"Lost {name} — reconnecting. The ride continues; pedal to wake the trainer."
))),
ConnectionState::Lost { reason } => Some(Notice::error(
status.error.clone().unwrap_or_else(|| format!("{name} unavailable: {reason}")),
)),
ConnectionState::Idle | ConnectionState::Scanning => None,
}
}
+864
View File
@@ -0,0 +1,864 @@
//! The trainer supervisor: the app's single owner of an [`FtmsClient`].
//!
//! Everything BLE-shaped happens in one background task. The Tauri commands and
//! the ride loop are synchronous and hold a `Mutex`, so they may never `.await`
//! a radio; they talk to this task over a channel instead, and read its results
//! from two `watch` channels:
//!
//! ```text
//! commands ──connect/target/release──► [supervisor task] ──► FtmsClient
//! ride loop ◄──watch<Telemetry>──────── │
//! device loop ◄──watch<TrainerStatus>────────┘
//! ```
//!
//! Three properties are load-bearing:
//!
//! * **SAF-2** — [`TrainerHandle::shutdown_blocking`] is callable from Tauri's
//! synchronous `RunEvent` handler, and waits for `FtmsClient::shutdown` to
//! actually finish. A fire-and-forget send would race the process exit and
//! leave the trainer loaded.
//! * **FR-9.3** — `control_acquired` is tracked separately from the connection
//! state. Connected is not controllable.
//! * **Never freeze on stale data** — if the trainer goes quiet while
//! nominally connected, the published telemetry is *zeroed* rather than held,
//! so the ride engine coasts to a stop and the UI visibly reacts instead of
//! showing a plausible-looking lie (FR-1.8).
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::{
Backoff, ControlOutcome, FtmsClient, FtmsConfig, FtmsError, FtmsEvent, TrainerSelector,
};
use bikecontrol_core::types::{ConnectionState, ControlTarget, SafetyLimits, Telemetry};
use serde::Serialize;
use tokio::sync::{broadcast, mpsc, watch};
/// How long the trainer may stay silent before its telemetry is treated as
/// stale. The D100 notifies at 4 Hz, so three seconds is ~12 missed frames.
const STALE_AFTER: Duration = Duration::from_secs(3);
/// Housekeeping tick — staleness only, so it can be lazy.
const HOUSEKEEPING: Duration = Duration::from_millis(500);
/// Upper bound on the SAF-2 sequence at app exit (NFR-9). Longer than the BLE
/// layer's own per-write timeouts, short enough not to hang a window close.
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(8);
/// Upper bound on the client's own reset sequence, kept under
/// [`SHUTDOWN_TIMEOUT`] so the supervisor always answers before the caller
/// stops listening. A caller that times out learns nothing and leaves.
const SAFETY_SEQUENCE_TIMEOUT: Duration = Duration::from_secs(7);
/// How long auto-reconnect keeps trying before it gives up and says so.
///
/// FR-1.11. Retrying forever sounds kinder than giving up, but it is not: the
/// link sits in `Reconnecting` indefinitely, the screen keeps implying the
/// trainer is on its way back, and the rider is never told to go and look at
/// it. Twenty attempts against the default backoff is about seven minutes.
const RECONNECT_ATTEMPTS: u32 = 20;
/// What the UI needs to know about the trainer link (FR-1.7, FR-9.3).
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TrainerStatus {
pub state: ConnectionState,
/// FTMS control point acquired. Deliberately *not* folded into `state`:
/// connected is not controllable (FR-9.3).
pub control_acquired: bool,
pub address: Option<String>,
pub name: Option<String>,
/// Human-readable failure, rendered verbatim (FR-9.2).
pub error: Option<String>,
/// Connected, but no Indoor Bike Data for [`STALE_AFTER`]. The rider needs
/// to know the numbers stopped being real (FR-1.8).
pub stale: bool,
}
impl Default for TrainerStatus {
fn default() -> Self {
Self {
state: ConnectionState::Idle,
control_acquired: false,
address: None,
name: None,
error: None,
stale: false,
}
}
}
impl TrainerStatus {
/// True once a ride would actually reach the trainer (FR-2.1).
pub fn controllable(&self) -> bool {
self.control_acquired && self.state == ConnectionState::Controlling
}
pub fn is_attached(&self) -> bool {
!matches!(self.state, ConnectionState::Idle | ConnectionState::Lost { .. })
}
}
enum Cmd {
Connect(TrainerSelector),
Disconnect,
Target(ControlTarget),
/// SAF-2 without dropping the link: used at the end of a ride, so the next
/// ride does not have to reconnect.
Release { limits: SafetyLimits },
/// Full SAF-2 sequence plus disconnect. Used on app exit.
Shutdown { reply: SyncSender<()> },
/// A spawned control write finished. Only reported when it failed.
WriteFailed(String),
}
/// Cheap, cloneable handle to the supervisor.
#[derive(Clone)]
pub struct TrainerHandle {
cmd_tx: mpsc::Sender<Cmd>,
status_rx: watch::Receiver<TrainerStatus>,
telemetry_rx: watch::Receiver<Telemetry>,
/// Shared, so every clone of the handle sees that SAF-2 has already run.
shut_down: Arc<AtomicBool>,
}
impl TrainerHandle {
/// Start the supervisor task. Must be called with a Tokio runtime available
/// — `tauri::async_runtime` provides one before the app is built.
pub fn spawn(config: FtmsConfig) -> Self {
let (cmd_tx, cmd_rx) = mpsc::channel(32);
let (status_tx, status_rx) = watch::channel(TrainerStatus::default());
let (telemetry_tx, telemetry_rx) = watch::channel(Telemetry::default());
let handle = Self {
cmd_tx: cmd_tx.clone(),
status_rx,
telemetry_rx,
shut_down: Arc::new(AtomicBool::new(false)),
};
tauri::async_runtime::spawn(run(cmd_rx, cmd_tx, config, status_tx, telemetry_tx));
handle
}
pub fn status(&self) -> TrainerStatus {
self.status_rx.borrow().clone()
}
/// Latest telemetry, for the ride engine. Zeroed while disconnected or
/// stale, never a held-over sample.
pub fn telemetry(&self) -> watch::Receiver<Telemetry> {
self.telemetry_rx.clone()
}
pub fn connect(&self, selector: TrainerSelector) {
self.send(Cmd::Connect(selector));
}
pub fn disconnect(&self) {
self.send(Cmd::Disconnect);
}
/// Push a control target. Fire-and-forget by design: the ride loop must not
/// block on the radio, and [`FtmsClient`] already coalesces so the newest
/// target wins (FR-2.8).
pub fn set_target(&self, target: ControlTarget) {
self.send(Cmd::Target(target));
}
/// SAF-2 at the end of a ride: zero gradient / minimum resistance, link kept.
pub fn release(&self, limits: SafetyLimits) {
self.send(Cmd::Release { limits });
}
/// SAF-2 at app exit: full reset sequence, then disconnect. Blocks the
/// calling (non-async) thread until it is done or [`SHUTDOWN_TIMEOUT`]
/// elapses.
/// Idempotent: Tauri delivers `ExitRequested`, `Exit` and window
/// `Destroyed` on one quit, and the second and third calls must be quiet
/// no-ops rather than warnings about a supervisor that has already done its
/// job.
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);
// Not `try_send` and give up: the one command that must not be dropped
// is this one, and a queue that is briefly full — the ride loop pushes a
// target every 250 ms — is not a reason to skip SAF-2. This is already a
// blocking call, so spending a little of its budget on getting the
// command in is free.
let mut cmd = Cmd::Shutdown { reply };
loop {
match self.cmd_tx.try_send(cmd) {
Ok(()) => break,
Err(mpsc::error::TrySendError::Closed(_)) => {
tracing::warn!("trainer supervisor already stopped; nothing to release");
return;
}
Err(mpsc::error::TrySendError::Full(returned)) => {
if Instant::now() >= deadline {
tracing::error!("could not reach the trainer supervisor to run SAF-2");
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!("trainer released (SAF-2)"),
Err(e) => tracing::error!(error = %e, "SAF-2 shutdown did not complete in time"),
}
}
fn send(&self, cmd: Cmd) {
if self.cmd_tx.try_send(cmd).is_err() {
// Capacity 32 against a 4 Hz producer: a full queue means the
// supervisor is wedged, which the status channel already reports.
tracing::warn!("trainer command dropped — supervisor queue full or closed");
}
}
}
// ---------------------------------------------------------------------------
// Supervisor
// ---------------------------------------------------------------------------
/// The client's three output streams are kept as separate locals rather than
/// one struct, so each `select!` arm borrows a distinct binding.
async fn run(
mut cmd_rx: mpsc::Receiver<Cmd>,
cmd_tx: mpsc::Sender<Cmd>,
config: FtmsConfig,
status_tx: watch::Sender<TrainerStatus>,
telemetry_tx: watch::Sender<Telemetry>,
) {
let mut client: Option<Arc<FtmsClient>> = None;
let mut telemetry_rx: Option<broadcast::Receiver<Telemetry>> = None;
let mut events_rx: Option<broadcast::Receiver<FtmsEvent>> = None;
let mut state_rx: Option<watch::Receiver<ConnectionState>> = None;
let mut last_sample: Option<Instant> = None;
let mut status = TrainerStatus::default();
loop {
tokio::select! {
// Telemetry first (NFR-2): control writes are rate-limited anyway.
biased;
sample = next_telemetry(&mut telemetry_rx) => match sample {
Some(sample) => {
last_sample = Some(Instant::now());
if status.stale {
status.stale = false;
publish(&status_tx, &status);
}
let _ = telemetry_tx.send(sample);
}
None => {
// The client actor stopped; the state watcher reports why.
telemetry_rx = None;
}
},
event = next_event(&mut events_rx) => match event {
Some(FtmsEvent::ControlFault { reason }) => {
tracing::error!(reason, "trainer control fault (SAF-4)");
status.control_acquired = false;
status.error = Some(reason);
publish(&status_tx, &status);
}
Some(_) => {}
// NFR-10. A closed stream is *ready* forever, and this select is
// biased: left in place it wins every poll and the command arm
// below is never reached again — including for the shutdown
// command. Drop it and let the state arm explain what happened.
None => events_rx = None,
},
state = next_state(&mut state_rx) => match state {
Some(state) => {
status.control_acquired = state == ConnectionState::Controlling;
if let ConnectionState::Lost { reason } = &state {
status.error = Some(reason.clone());
// A lost link must not leave the last sample standing.
let _ = telemetry_tx.send(Telemetry::default());
last_sample = None;
}
status.state = state;
publish(&status_tx, &status);
}
// The client actor stopped without being asked to — it spent its
// reconnect budget (FR-1.11). Everything we hold is now a zombie:
// the handle would fail one write at a time with nothing to
// explain it, and the closed streams would spin the loop.
None => {
telemetry_rx = None;
events_rx = None;
state_rx = None;
client = None;
last_sample = None;
let _ = telemetry_tx.send(Telemetry::default());
let reason = status.error.clone().unwrap_or_else(|| {
"the trainer link ended and could not be re-established".to_string()
});
tracing::warn!(reason, "trainer supervisor lost its client");
status.control_acquired = false;
status.stale = false;
// Address and name stay: the rider needs a row to click on
// to try again (FR-1.12).
status.state = ConnectionState::Lost { reason };
publish(&status_tx, &status);
}
},
cmd = cmd_rx.recv() => match cmd {
None => break,
Some(Cmd::Shutdown { reply }) => {
let subs = (&mut telemetry_rx, &mut events_rx, &mut state_rx);
shutdown(client.take(), subs, &telemetry_tx).await;
status = TrainerStatus::default();
publish(&status_tx, &status);
let _ = reply.send(());
break;
}
Some(Cmd::Disconnect) => {
let subs = (&mut telemetry_rx, &mut events_rx, &mut state_rx);
shutdown(client.take(), subs, &telemetry_tx).await;
last_sample = None;
// Keep who it was. A trainer stops advertising the moment it
// is in use, so a scan will not necessarily put it back —
// wiping the address here drops it out of the device list
// altogether and leaves the rider nothing to click on to
// reconnect (FR-1.12).
let (address, name) = (status.address.take(), status.name.take());
status = TrainerStatus { address, name, ..TrainerStatus::default() };
publish(&status_tx, &status);
}
Some(Cmd::Connect(selector)) => {
// Authoritative guard against a duplicate click racing the
// status watch: reconnecting a live session would drop the
// rider's trainer mid-ride for no reason.
if client.is_some() && status.is_attached() && already_selected(&status, &selector) {
tracing::debug!(selector = selector.describe(), "already connected; ignoring");
continue;
}
let subs = (&mut telemetry_rx, &mut events_rx, &mut state_rx);
shutdown(client.take(), subs, &telemetry_tx).await;
last_sample = None;
status = TrainerStatus {
state: ConnectionState::Connecting,
..TrainerStatus::default()
};
publish(&status_tx, &status);
// FR-1.10 / SAF-8. A connect runs for up to `scan_timeout`
// plus connect, discovery and handshake — far longer than
// the shutdown budget. Awaiting it here without a way out is
// what makes a quit-while-connecting skip SAF-2 entirely and
// leave the rider on a loaded trainer, so the attempt is
// abandoned instead of finished.
let mut abort: Option<Abort> = None;
let outcome = {
let cancel = async {
abort = Some(abort_signal(&mut cmd_rx, &selector).await);
};
FtmsClient::connect_cancellable(
selector.clone(),
config.clone(),
cancel,
)
.await
};
match outcome {
Ok(Some(c)) => {
tracing::info!(
address = c.address(),
name = c.name().unwrap_or("(no name)"),
caps = ?c.capabilities(),
"trainer connected and controllable"
);
telemetry_rx = Some(c.telemetry());
events_rx = Some(c.events());
state_rx = Some(c.state_stream());
status = TrainerStatus {
state: c.state(),
control_acquired: c.state() == ConnectionState::Controlling,
address: Some(c.address().to_string()),
name: c.name().map(str::to_string),
error: None,
stale: false,
};
client = Some(Arc::new(c));
last_sample = Some(Instant::now());
}
Ok(None) => {
tracing::info!(
selector = selector.describe(),
"connect abandoned; the link it had opened is closed"
);
status = TrainerStatus::default();
}
Err(e) => {
tracing::warn!(error = %e, selector = selector.describe(), "trainer connect failed");
status = TrainerStatus {
state: ConnectionState::Lost { reason: e.to_string() },
error: Some(connect_hint(&e)),
..TrainerStatus::default()
};
}
}
publish(&status_tx, &status);
// Whatever interrupted the connect still has to be honoured.
match abort {
None => {}
Some(Abort::Disconnect) => {
let subs = (&mut telemetry_rx, &mut events_rx, &mut state_rx);
shutdown(client.take(), subs, &telemetry_tx).await;
last_sample = None;
let (address, name) = (status.address.take(), status.name.take());
status = TrainerStatus { address, name, ..TrainerStatus::default() };
publish(&status_tx, &status);
}
Some(Abort::Connect(next)) => {
// Back of the queue rather than a recursive call:
// the loop picks it up on its next turn, once this
// attempt has been fully unwound.
let subs = (&mut telemetry_rx, &mut events_rx, &mut state_rx);
shutdown(client.take(), subs, &telemetry_tx).await;
last_sample = None;
if cmd_tx.try_send(Cmd::Connect(next)).is_err() {
tracing::warn!("could not re-queue the trainer the rider picked");
}
}
Some(Abort::Shutdown(reply)) => {
let subs = (&mut telemetry_rx, &mut events_rx, &mut state_rx);
shutdown(client.take(), subs, &telemetry_tx).await;
status = TrainerStatus::default();
publish(&status_tx, &status);
let _ = reply.send(());
break;
}
Some(Abort::Closed) => break,
}
}
Some(Cmd::Target(target)) => {
if let Some(c) = client.clone() {
let back = cmd_tx.clone();
// Spawned, not awaited: an unacknowledged write takes up
// to `ack_timeout`, and the telemetry pump must keep
// running through it.
tauri::async_runtime::spawn(async move {
match c.set_target(target).await {
Ok(ControlOutcome::Acknowledged { sent }) => {
tracing::debug!(?sent, "trainer accepted target")
}
Ok(ControlOutcome::Superseded) => {}
Err(e) => {
tracing::warn!(?target, error = %e, "control write failed");
let _ = back.try_send(Cmd::WriteFailed(e.to_string()));
}
}
});
}
}
Some(Cmd::Release { limits }) => {
if let Some(c) = client.clone() {
let safe = limits.clamp(ControlTarget::Gradient { percent: 0.0 });
tauri::async_runtime::spawn(async move {
if let Err(e) = c.set_target(safe).await {
tracing::warn!(error = %e, "SAF-2 release write failed");
}
});
}
}
Some(Cmd::WriteFailed(reason)) => {
if status.error.as_deref() != Some(reason.as_str()) {
status.error = Some(reason);
publish(&status_tx, &status);
}
}
},
_ = tokio::time::sleep(HOUSEKEEPING) => {
let quiet = last_sample.is_some_and(|t| t.elapsed() >= STALE_AFTER);
if quiet && !status.stale && status.is_attached() {
tracing::warn!("no Indoor Bike Data for {STALE_AFTER:?} — zeroing telemetry");
status.stale = true;
publish(&status_tx, &status);
// Zero rather than hold: a frozen readout is worse than an
// obviously dead one.
let _ = telemetry_tx.send(Telemetry::default());
}
},
}
}
tracing::info!("trainer supervisor stopped");
}
/// A command that arrived while a connect was in flight and means "stop"
/// (FR-1.10). It is carried out of the attempt rather than acted on there,
/// because the attempt has to be unwound first.
enum Abort {
Disconnect,
Shutdown(SyncSender<()>),
/// The rider picked a different trainer while this one was still connecting.
Connect(TrainerSelector),
/// Every handle dropped.
Closed,
}
/// Watch for a reason to abandon a connect that is currently running.
///
/// Resolves only on a command that must interrupt the attempt. Targets and
/// releases arriving mid-connect are consumed and dropped rather than left to
/// pile up: there is no link to write them to, and the ride loop re-sends its
/// target every tick. Draining is half the point — a command channel nobody
/// reads for fifteen seconds is a command channel that fills.
async fn abort_signal(cmd_rx: &mut mpsc::Receiver<Cmd>, connecting_to: &TrainerSelector) -> 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,
// Picking a *different* trainer means "not that one, this one".
// Finishing the first attempt before starting the second would make
// the rider wait out a scan timeout for a choice they have already
// changed. The same trainer clicked twice is only impatience.
Some(Cmd::Connect(selector)) if selector != *connecting_to => {
return Abort::Connect(selector)
}
Some(_) => tracing::trace!("command dropped: nothing to send it to yet"),
}
}
}
/// Run SAF-2 and drop the client.
#[allow(clippy::type_complexity)]
async fn shutdown(
client: Option<Arc<FtmsClient>>,
subs: (
&mut Option<broadcast::Receiver<Telemetry>>,
&mut Option<broadcast::Receiver<FtmsEvent>>,
&mut Option<watch::Receiver<ConnectionState>>,
),
telemetry_tx: &watch::Sender<Telemetry>,
) {
*subs.0 = None;
*subs.1 = None;
*subs.2 = None;
let _ = telemetry_tx.send(Telemetry::default());
let Some(client) = client else { return };
// Bounded, so the supervisor always answers `shutdown_blocking` before that
// caller stops listening. A caller that times out reports a failure it
// cannot do anything about and then lets the process exit anyway.
match tokio::time::timeout(SAFETY_SEQUENCE_TIMEOUT, client.shutdown_ref()).await {
Ok(Ok(())) => {}
Ok(Err(e)) => tracing::warn!(error = %e, "SAF-2 shutdown reported an error"),
Err(_) => tracing::error!("SAF-2 shutdown did not finish inside its budget"),
}
}
/// Does `selector` name the trainer we are already attached to?
fn already_selected(status: &TrainerStatus, selector: &TrainerSelector) -> bool {
match selector {
TrainerSelector::Any => true,
TrainerSelector::Address(a) => status
.address
.as_deref()
.is_some_and(|current| current.eq_ignore_ascii_case(a)),
TrainerSelector::NameContains(n) => status
.name
.as_deref()
.is_some_and(|current| current.to_lowercase().contains(&n.to_lowercase())),
}
}
fn publish(tx: &watch::Sender<TrainerStatus>, status: &TrainerStatus) {
let _ = tx.send(status.clone());
}
/// FR-1.8: an empty scan means "nothing was advertising", and the fix is almost
/// always to pedal. Say so rather than reporting a bare error.
fn connect_hint(e: &FtmsError) -> String {
match e {
FtmsError::NotFound(_) => {
"Trainer not found. It only advertises once awake — turn the cranks for a few \
seconds and try again."
.to_string()
}
FtmsError::NoAdapter => {
"No Bluetooth adapter. Check the radio is on and BlueZ is running.".to_string()
}
// A-3: the D100 accepts one BLE host. A second one gets the link torn
// down mid-handshake, which surfaces from BlueZ as a bare "Not
// connected" — the least informative possible description of the most
// common real-world failure.
FtmsError::Bluetooth(_) | FtmsError::MissingCharacteristic(_) => format!(
"Trainer is busy: {e}. It accepts one connection at a time — close any other app \
(or `probe`) that is holding it, then try again."
),
FtmsError::Rejected { .. } | FtmsError::Unacknowledged { .. } => format!(
"{e}. Another app may already hold control of the trainer — only one may at a time."
),
other => other.to_string(),
}
}
// -- select! helpers ---------------------------------------------------------
//
// Each parks forever when there is no client, so the other arms drive the loop.
// The one thing none of them may do is return `Ready` on every poll: the select
// they feed is `biased`, so a permanently-ready arm starves every arm below it
// (NFR-10). That is why a closed stream is reported once and the caller then
// clears the receiver.
async fn next_telemetry(rx: &mut Option<broadcast::Receiver<Telemetry>>) -> Option<Telemetry> {
match rx {
None => std::future::pending().await,
Some(rx) => loop {
match rx.recv().await {
Ok(t) => return Some(t),
// NFR-2: a lagged consumer skips ahead, it does not stall.
Err(broadcast::error::RecvError::Lagged(n)) => {
tracing::debug!(skipped = n, "telemetry consumer lagged")
}
Err(broadcast::error::RecvError::Closed) => return None,
}
},
}
}
async fn next_event(rx: &mut Option<broadcast::Receiver<FtmsEvent>>) -> Option<FtmsEvent> {
match rx {
None => std::future::pending().await,
Some(rx) => loop {
match rx.recv().await {
Ok(e) => return Some(e),
Err(broadcast::error::RecvError::Lagged(_)) => {}
Err(broadcast::error::RecvError::Closed) => return None,
}
},
}
}
async fn next_state(rx: &mut Option<watch::Receiver<ConnectionState>>) -> Option<ConnectionState> {
match rx {
None => std::future::pending().await,
Some(rx) => match rx.changed().await {
Ok(()) => Some(rx.borrow_and_update().clone()),
Err(_) => None,
},
}
}
/// The FTMS configuration this app rides with.
///
/// `use_simulation_mode` is **true**, unlike the crate default: the D100
/// advertises `SetIndoorBikeSimulationParameters` (`0x11`, target feature bit
/// 13) and accepts it, while it does *not* advertise `SetTargetInclination`
/// (`0x03`) and its inclination range reports 06 % with no negatives — useless
/// for descents. Measured against firmware 0.108; see README.md.
pub fn app_config(limits: SafetyLimits) -> FtmsConfig {
FtmsConfig {
limits,
use_simulation_mode: true,
backoff: Backoff {
max_attempts: Some(RECONNECT_ATTEMPTS),
..Backoff::default()
},
..FtmsConfig::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn control_is_tracked_separately_from_connection() {
// FR-9.3: connected is not controllable.
let connected = TrainerStatus {
state: ConnectionState::Connected,
control_acquired: false,
..TrainerStatus::default()
};
assert!(connected.is_attached());
assert!(!connected.controllable());
let controlling = TrainerStatus {
state: ConnectionState::Controlling,
control_acquired: true,
..TrainerStatus::default()
};
assert!(controlling.controllable());
}
#[test]
fn a_lost_link_is_not_attached() {
let lost = TrainerStatus {
state: ConnectionState::Lost { reason: "gone".into() },
..TrainerStatus::default()
};
assert!(!lost.is_attached());
assert!(!lost.controllable());
assert!(!TrainerStatus::default().is_attached());
}
#[test]
fn the_app_drives_gradient_through_simulation_mode() {
// The D100 rejects 0x03 and accepts 0x11 — measured, see README.
let cfg = app_config(SafetyLimits::default());
assert!(cfg.use_simulation_mode);
assert!(!cfg.ignore_advertised_features, "FR-2.6 is not for the app to bypass");
assert!(cfg.start_on_connect);
assert!(cfg.min_write_interval >= Duration::from_millis(250), "FR-2.8");
}
#[test]
fn safety_limits_reach_the_ble_layer() {
let limits = SafetyLimits { max_gradient_pct: 8.0, ..SafetyLimits::default() };
assert_eq!(app_config(limits).limits.max_gradient_pct, 8.0);
}
#[test]
fn a_missing_trainer_is_explained_not_just_reported() {
let hint = connect_hint(&FtmsError::NotFound("any FTMS trainer".into()));
assert!(hint.to_lowercase().contains("crank"), "FR-1.8: {hint}");
let hint = connect_hint(&FtmsError::NoAdapter);
assert!(hint.to_lowercase().contains("bluetooth"));
}
#[test]
fn a_second_connect_to_the_same_trainer_is_recognised() {
// A duplicate click must not tear down a live session.
let status = TrainerStatus {
state: ConnectionState::Controlling,
control_acquired: true,
address: Some("94:05:bb:04:76:28".into()),
name: Some("VANRYSEL-HT-2876".into()),
..TrainerStatus::default()
};
assert!(already_selected(
&status,
&TrainerSelector::Address("94:05:BB:04:76:28".into())
));
assert!(already_selected(
&status,
&TrainerSelector::NameContains("vanrysel".into())
));
assert!(already_selected(&status, &TrainerSelector::Any));
// A different trainer is a genuine reconnect.
assert!(!already_selected(
&status,
&TrainerSelector::Address("aa:bb:cc:dd:ee:ff".into())
));
assert!(!already_selected(
&TrainerStatus::default(),
&TrainerSelector::Address("94:05:bb:04:76:28".into())
));
}
#[tokio::test]
async fn a_shutdown_interrupts_a_connect_instead_of_queueing_behind_it() {
// FR-1.10 / SAF-8. A connect runs for up to `scan_timeout` plus the
// handshake; reading the shutdown command only after it finished is what
// made a quit-while-connecting blow its budget and skip SAF-2 entirely.
let (tx, mut rx) = mpsc::channel(8);
let (reply, done) = sync_channel(1);
// Commands that are not a reason to stop are drained, not left to fill
// the queue while nobody is reading it.
tx.send(Cmd::Target(ControlTarget::Gradient { percent: 3.0 }))
.await
.unwrap();
tx.send(Cmd::Release { limits: SafetyLimits::default() })
.await
.unwrap();
tx.send(Cmd::Shutdown { reply }).await.unwrap();
match abort_signal(&mut rx, &TrainerSelector::Any).await {
Abort::Shutdown(reply) => {
// The caller is blocking on this; it has to be answerable.
let _ = reply.send(());
assert!(done.recv().is_ok());
}
_ => panic!("the shutdown did not interrupt the connect"),
}
}
#[tokio::test]
async fn a_disconnect_interrupts_a_connect_too() {
// Otherwise the rider's Disconnect click does nothing for fifteen
// seconds and then tears down the link it just finished building.
let (tx, mut rx) = mpsc::channel(8);
tx.send(Cmd::Disconnect).await.unwrap();
assert!(matches!(
abort_signal(&mut rx, &TrainerSelector::Any).await,
Abort::Disconnect
));
}
#[tokio::test]
async fn picking_a_different_trainer_mid_connect_takes_over() {
// Otherwise changing your mind costs a whole scan timeout, and the
// trainer you clicked second is connected to only after the one you
// gave up on has finished failing.
let connecting_to = TrainerSelector::Address("94:05:bb:04:76:28".into());
let (tx, mut rx) = mpsc::channel(8);
// The same trainer clicked again is impatience, not a new intent — it
// must not restart the attempt already running.
tx.send(Cmd::Connect(connecting_to.clone())).await.unwrap();
tx.send(Cmd::Connect(TrainerSelector::Address("aa:bb:cc:dd:ee:ff".into())))
.await
.unwrap();
match abort_signal(&mut rx, &connecting_to).await {
Abort::Connect(TrainerSelector::Address(a)) => assert_eq!(a, "aa:bb:cc:dd:ee:ff"),
_ => panic!("the second trainer did not take over"),
}
}
#[tokio::test]
async fn dropping_every_handle_ends_a_connect() {
let (tx, mut rx) = mpsc::channel::<Cmd>(1);
drop(tx);
assert!(matches!(
abort_signal(&mut rx, &TrainerSelector::Any).await,
Abort::Closed
));
}
#[test]
fn reconnect_is_bounded_so_the_rider_is_eventually_told() {
// FR-1.11. Retrying forever sounds kinder than giving up, but it leaves
// the link in `Reconnecting` indefinitely and never says the trainer is
// gone — so the rider is never prompted to go and look at it.
let cfg = app_config(SafetyLimits::default());
assert_eq!(cfg.backoff.max_attempts, Some(RECONNECT_ATTEMPTS));
assert!(cfg.backoff.exhausted(RECONNECT_ATTEMPTS));
assert!(!cfg.backoff.exhausted(RECONNECT_ATTEMPTS - 1));
}
#[test]
fn the_supervisor_answers_before_its_caller_stops_listening() {
// NFR-9. If the inner budget outlasted the outer one, every quit would
// report a timeout for a sequence that was about to succeed — and then
// let the process exit on top of it anyway.
assert!(SAFETY_SEQUENCE_TIMEOUT < SHUTDOWN_TIMEOUT);
}
#[test]
fn a_trainer_held_by_another_app_says_so() {
// A-3: one BLE host. BlueZ reports the second one's torn-down link as a
// bare "Not connected", which explains nothing on its own.
let hint = connect_hint(&FtmsError::MissingCharacteristic("Indoor Bike Data (0x2AD2)"));
assert!(hint.contains("one connection at a time"), "{hint}");
assert!(hint.to_lowercase().contains("busy"), "{hint}");
}
}
File diff suppressed because it is too large Load Diff
+61 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { app } from './lib/app.svelte';
import { api, inTauri } from './lib/bridge';
import { api, inTauri, type ControllerInput } from './lib/bridge';
import ConnectionScreen from './components/ConnectionScreen.svelte';
import HelpOverlay from './components/HelpOverlay.svelte';
import ProfileDrawer from './components/ProfileDrawer.svelte';
@@ -17,7 +17,7 @@
return;
}
try {
await app.init();
await app.init({ onControllerInput });
ready = true;
} catch (e) {
fatal = String(e);
@@ -88,6 +88,65 @@
if (ride.mode === 'Resistance') return api.setResistance(ride.resistanceLevel + dir * 2);
return api.nudgeGradient(dir * 0.5);
}
/**
* Zwift Click input. The paddles shift "gears" and the D-pad drives the UI.
*
* Every button routes to an intent the keyboard already has, rather than to a
* second implementation — that is the whole point of doing this here instead
* of in Rust. If a shortcut changes, the controller follows it for free.
*
* Only press edges act. The Rust side already filters the pod's ~10 Hz repeat
* while a button is held, so acting on releases too would double every shift.
*/
function onControllerInput(input: ControllerInput) {
if (!input.pressed) return;
const run = (fn: () => Promise<unknown>) => app.run(fn);
switch (input.button) {
// Paddles: a gear is ±10 W of load (or the closest thing the mode has).
case 'plus':
return run(() => shiftGear(1));
case 'minus':
return run(() => shiftGear(-1));
// D-pad: gradient on the vertical axis, screens on the horizontal.
case 'up':
return run(() => api.nudgeGradient(0.5));
case 'down':
return run(() => api.nudgeGradient(-0.5));
case 'left':
app.screen = 'connect';
return;
case 'right':
app.screen = 'ride';
return;
// Face buttons mirror the existing single-key shortcuts.
case 'a':
return run(() => api.togglePause());
case 'b':
return run(() => api.markLap());
case 'y':
return run(() => api.cycleMode());
case 'z':
app.showProfiles = !app.showProfiles;
return;
}
}
/**
* One "gear" of load. Power modes move in 10 W steps; resistance mode has no
* watt unit, so it moves one level, and gradient modes fall back to the
* existing nudge so the paddles are never dead.
*/
async function shiftGear(dir: number): Promise<unknown> {
const ride = app.ride;
if (!ride) return;
if (ride.mode === 'Erg') return api.setPower(ride.powerTargetW + dir * 10);
if (ride.mode === 'Resistance') return api.setResistance(ride.resistanceLevel + dir);
return api.nudgeGradient(dir * 0.5);
}
</script>
<svelte:window on:keydown={onKey} />
+30
View File
@@ -60,6 +60,36 @@
</div>
</header>
<!--
The Zwift Click is deliberately not in the device list below: that list is
FTMS trainers, and a controller is a different kind of thing with a
different failure mode (it sleeps in seconds and must be woken by hand).
-->
<div class="gate">
<span class="dot {app.controller?.connected ? 'tone-ok' : 'tone-warn'}"></span>
<span>
{#if app.controller?.connected}
Zwift Click connected{app.controller.batteryPercent != null
? ` — battery ${app.controller.batteryPercent}%`
: ''}. Paddles shift; the D-pad drives the UI.
{:else if app.controller?.error}
Controller: {app.controller.error}
{:else}
No controller. <strong>Press a button on the Click first</strong> — it only advertises
while awake.
{/if}
</span>
{#if app.controller?.connected}
<button class="btn ghost" onclick={() => app.run(() => api.disconnectController())}>
Disconnect
</button>
{:else}
<button class="btn ghost" onclick={() => app.run(() => api.connectController())}>
Connect Click
</button>
{/if}
</div>
{#if !trainerReady}
<div class="gate">
<span class="dot tone-warn"></span>
+15 -2
View File
@@ -10,6 +10,13 @@
const ride = $derived(app.ride);
const running = $derived(ride?.status === 'running');
/**
* Before a ride starts, the gradient trim, the lap marker and End have
* nothing to act on. The ride screen is showing a large Start button at that
* point, and nine similar-looking buttons beside it only make it harder to
* find.
*/
const started = $derived(ride?.status === 'running' || ride?.status === 'paused');
let flash = $state<string | null>(null);
let flashTimer: ReturnType<typeof setTimeout> | null = null;
@@ -26,6 +33,7 @@
</script>
<div class="bar">
{#if started}
<div class="group">
<button class="btn" class:lit={flash === 'gradient'} onclick={() => grade(-0.5)}>
<span class="glyph"></span> Grade <span class="kbd"></span>
@@ -41,20 +49,23 @@
Zero <span class="kbd">0</span>
</button>
</div>
{/if}
<div class="group">
<button class="btn" class:lit={flash === 'mode'} onclick={() => app.run(() => api.cycleMode())}>
Mode: {MODE_LABEL[ride?.mode ?? 'ManualGrade']} <span class="kbd">M</span>
</button>
<button class="btn ghost" onclick={() => (app.showProfiles = true)}>
Profile <span class="kbd">P</span>
<button class="btn" onclick={() => (app.showProfiles = true)}>
Route <span class="kbd">P</span>
</button>
</div>
<div class="group right">
{#if started}
<button class="btn" class:lit={flash === 'lap'} onclick={() => app.run(() => api.markLap())}>
Lap {ride?.lap ?? 1} <span class="kbd">L</span>
</button>
{/if}
{#if running}
<button class="btn" class:lit={flash === 'toggle-pause'} onclick={() => app.run(() => api.togglePause())}>
Pause <span class="kbd"></span>
@@ -64,7 +75,9 @@
{ride?.status === 'paused' ? 'Resume' : 'Start ride'} <span class="kbd"></span>
</button>
{/if}
{#if started}
<button class="btn danger" onclick={() => app.run(() => api.stop())}>End</button>
{/if}
<button class="btn ghost" onclick={() => (app.showHelp = !app.showHelp)} title="Keyboard shortcuts">
<span class="kbd">?</span>
</button>
+35 -5
View File
@@ -1,11 +1,23 @@
<script lang="ts">
/**
* Keyboard shortcuts (FR-3.19). These exist because there is no physical
* controller in this phase — and because if a Click pod dies mid-ride, the
* keyboard is the only way to keep the session going.
* Keyboard shortcuts (FR-3.19), and the Zwift Click buttons that mirror them.
* The keyboard remains the fallback: if a Click pod dies or its battery goes
* mid-ride, it is the only way to keep the session going.
*/
import { app } from '../lib/app.svelte';
/** Kept beside the keyboard list so the two cannot drift apart on screen —
* they already share one implementation in `App.svelte`. */
const CONTROLLER: [string, string][] = [
['+ / ', 'Shift a gear: ±10 W, or one resistance level'],
['D-pad ↑ / ↓', 'Gradient +0.5% / 0.5%'],
['D-pad ← / →', 'Device screen / ride screen'],
['A', 'Pause / resume'],
['B', 'Insert lap marker'],
['Y', 'Cycle control mode'],
['Z', 'Profiles and routes'],
];
const BINDINGS: [string, string][] = [
['↑ / ↓', 'Gradient +0.5% / 0.5%'],
['Shift + ↑ / ↓', 'Gradient ±2% (coarse)'],
@@ -39,10 +51,28 @@
</div>
{/each}
</dl>
<h2>Zwift Click</h2>
<dl>
{#each CONTROLLER as [key, what]}
<div>
<dt><span class="kbd">{key}</span></dt>
<dd>{what}</dd>
</div>
{/each}
</dl>
{#if app.controller?.connected}
<p class="note">
Every one of these has an on-screen equivalent in the control bar, and each will map to a
Zwift Click button once the controller client lands.
Controller connected{app.controller.batteryPercent != null
? ` — battery ${app.controller.batteryPercent}%`
: ''}.
</p>
{:else}
<p class="note">
No controller connected. A Click only advertises after a button press, so wake it and
connect from the device screen.
</p>
{/if}
<p class="note">Every one of these has an on-screen equivalent in the control bar.</p>
</div>
<style>
+251 -12
View File
@@ -30,6 +30,40 @@
const ride = $derived(app.ride);
const profile = $derived(ride?.profile ?? null);
/**
* The trainer chip (FR-1.8, FR-9.3). The rider must never be left reading
* zeros without being told why, so every non-controlling state gets words.
*/
const trainerChip = $derived.by(() => {
if (ride?.source === 'mock') {
return { tone: 'tone-warn', label: 'Simulated — no trainer' };
}
const t = ride?.trainer;
if (!t) return null;
const state = t.state;
if (typeof state === 'object' && 'Lost' in state) {
return { tone: 'tone-bad', label: `Trainer unavailable — ${state.Lost.reason}` };
}
switch (state) {
case 'Idle':
return { tone: 'tone-warn', label: 'No trainer — open Devices to connect' };
case 'Scanning':
return { tone: 'tone-warn', label: 'Scanning for the trainer…' };
case 'Connecting':
return { tone: 'tone-warn', label: 'Connecting to the trainer…' };
case 'Reconnecting':
return { tone: 'tone-warn', label: 'Trainer lost — reconnecting' };
case 'Connected':
return { tone: 'tone-warn', label: 'Connected, control not acquired' };
case 'Controlling':
if (t.stale) return { tone: 'tone-warn', label: 'Trainer silent — pedal to wake it' };
if (t.error) return { tone: 'tone-bad', label: t.error };
return null;
default:
return null;
}
});
const gradient = $derived(snap?.gradient_pct ?? 0);
const gradeColour = $derived(
gradient > 0.4 ? 'var(--climb)' : gradient < -0.4 ? 'var(--route)' : 'var(--ink)',
@@ -77,6 +111,18 @@
return d.distanceRemainingM;
});
/**
* FR-7.5 — the trainer's own speed reading is diagnostic, so it sits beside
* the virtual speed rather than replacing it. A trainer that reports nothing
* (or, as on the D100, in units we have not confirmed) is then visible as a
* mismatch instead of silently absent.
*/
const speedSub = $derived.by(() => {
const now = `now ${num(snap?.virtual_speed_kph ?? 0, 1)}`;
const trainer = snap?.telemetry.speed_kph;
return trainer != null ? `${now} · trainer ${num(trainer, 1)}` : now;
});
const statusChip = $derived.by(() => {
switch (ride?.status) {
case 'running':
@@ -89,27 +135,66 @@
return { label: 'Ready', tone: 'tone-idle' };
}
});
/**
* A ride that has not started yet. Before the first pedal stroke almost
* everything on this screen reads zero, so the screen's job is not to show
* numbers — it is to show the one action that matters.
*/
const preRide = $derived(ride?.status !== 'running' && ride?.status !== 'paused');
/**
* The rider is looking at invented data. This is never inferred from a
* missing trainer — the app shows zeros for that — it is only ever true
* because someone asked for it with `BIKECONTROL_DEMO`/`BIKECONTROL_MOCK`.
* It still gets a banner, because a session you cannot tell from a real one
* is worse than no session at all.
*/
const simulated = $derived(ride?.source === 'mock');
/** Route length and climbing, said plainly, so "what is loaded" is obvious. */
const routeSummary = $derived.by(() => {
if (!profile) return null;
const bits: string[] = [];
if (profile.totalMetres) bits.push(`${(profile.totalMetres / 1000).toFixed(1)} km`);
if (profile.totalSeconds) bits.push(`${Math.round(profile.totalSeconds / 60)} min`);
if (profile.totalAscentM != null) bits.push(`${profile.totalAscentM.toFixed(0)} m up`);
if (profile.looping) bits.push('loops');
return bits.join(' · ');
});
const openRoutes = () => (app.showProfiles = true);
</script>
<div class="ride">
<div class="ride" class:simulated>
{#if simulated}
<!-- Not a chip, not a toast: a rider must not be able to finish a session
and only then find out none of it was real. -->
<div class="sim-banner">
<strong>Simulated ride</strong>
<span>Power, speed and distance are fabricated. No trainer is being read.</span>
</div>
{/if}
<!-- Header: what is loaded, what mode, what target (FR-9.8). -->
<header>
<div class="who">
<h1>{profile?.name ?? 'No route'}</h1>
{#if profile?.description}
<p>{profile.description}</p>
<h1>{profile?.name ?? 'No route loaded'}</h1>
{#if routeSummary}
<p>{routeSummary}{profile?.description ? ` — ${profile.description}` : ''}</p>
{:else}
<p>Manual control — load a profile to ride terrain.</p>
<p>Manual control — choose a route to ride real terrain.</p>
{/if}
</div>
<div class="chips">
{#if ride?.source === 'mock'}
<span class="chip tone-warn"><span class="dot"></span>Simulated — no trainer</span>
{#if trainerChip}
<span class="chip {trainerChip.tone}"><span class="dot"></span>{trainerChip.label}</span>
{/if}
<span class="chip {statusChip.tone}"><span class="dot"></span>{statusChip.label}</span>
<span class="chip mode">{MODE_LABEL[ride?.mode ?? 'ManualGrade']}</span>
<span class="chip target">Target {targetText(ride?.target ?? null)}</span>
<button class="btn" onclick={openRoutes}>Route <span class="kbd">P</span></button>
<button class="btn ghost" onclick={() => (app.screen = 'connect')}>Devices</button>
</div>
</header>
@@ -119,6 +204,33 @@
<RouteChart {profile} positionX={d?.positionX ?? 0} revision={app.revision} />
</section>
{#if preRide}
<!-- The whole screen before a ride begins: one obvious action. -->
<section class="launch">
{#if profile}
<button class="start" onclick={() => app.run(() => api.start())}>
{ride?.status === 'finished' ? 'Ride again' : 'Start ride'}
<span class="kbd"></span>
</button>
<div class="launch-aside">
<span class="launch-route">{profile.name}</span>
<span class="launch-sub">{routeSummary}</span>
<button class="btn ghost" onclick={openRoutes}>Choose a different route</button>
</div>
{:else}
<button class="start" onclick={openRoutes}>Choose a route</button>
<div class="launch-aside">
<span class="launch-sub">
Pick a bundled route or open your own GPX. Or start now and ride on manual gradient.
</span>
<button class="btn ghost" onclick={() => app.run(() => api.start())}>
Start without a route
</button>
</div>
{/if}
</section>
{/if}
<!-- Primary readouts. -->
<section class="primary">
<Readout
@@ -142,7 +254,7 @@
value={num(d?.smoothedSpeedKph ?? 0, 1)}
unit="km/h"
size="big"
sub={`now ${num(snap?.virtual_speed_kph ?? 0, 1)}`}
sub={speedSub}
/>
<Readout
label="Gradient"
@@ -197,6 +309,8 @@
size="small"
/>
<Readout label="Work" value={num(d?.energyKj ?? 0, 0)} unit="kJ" size="small" />
<!-- An estimate, not a measurement — see `bikecontrol_core::energy`. -->
<Readout label="Burned" value={num(d?.caloriesKcal ?? 0, 0)} unit="kcal" size="small" />
<div class="spacer"></div>
<div class="charts">
<div class="chart">
@@ -226,11 +340,56 @@
</div>
<style>
/*
* A column, not a fixed grid.
*
* This used to be `grid-template-rows` with pixel minimums that together
* exceeded a short window. When they did, the tracks overflowed and the
* chart section was painted straight over the control bar — and because
* uPlot positions its canvas and its `.u-over` overlay, both of which are
* `position: relative/absolute`, they painted *above* the unpositioned
* buttons and swallowed every click on them. The Start ride button was
* visible, looked enabled, and did nothing.
*
* Flex items cannot overlap, the flexible sections absorb the slack, and
* `overflow: hidden` on the chart area means a canvas that has not yet been
* resized cannot escape it either. The control bar keeps its own stacking
* context as a final guarantee.
*/
.ride {
display: grid;
grid-template-rows: auto minmax(150px, 1fr) auto auto minmax(190px, 0.95fr) auto;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
.ride > :global(*) {
flex: none;
}
.sim-banner {
display: flex;
align-items: baseline;
gap: 0.7rem;
flex-wrap: wrap;
padding: 0.55rem var(--edge);
background: var(--warn);
color: #1a1400;
font-size: 0.92rem;
}
.sim-banner strong {
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
font-size: 0.8rem;
}
/* A hairline of the same warning colour all the way round the ride, so the
state is legible from the corner of the eye at any scroll position. */
.ride.simulated {
box-shadow: inset 0 0 0 2px var(--warn);
}
header {
@@ -283,8 +442,78 @@
}
.route {
min-height: 0;
flex: 2 1 0;
min-height: 130px;
padding: 0 var(--edge);
overflow: hidden;
}
/* ---- pre-ride launch panel ---------------------------------------- */
.launch {
display: flex;
align-items: center;
gap: var(--gap);
flex-wrap: wrap;
padding: 1.1rem var(--edge);
margin: 0.9rem var(--edge) 0.2rem;
border-radius: 0.7rem;
background: var(--bg-lift);
}
/* The single most important control on the screen before a ride, and it must
look like it. */
.start {
display: inline-flex;
align-items: center;
gap: 0.7em;
padding: 0.85em 2em;
border-radius: 0.6rem;
background: var(--route);
color: #04121a;
font-size: clamp(1.1rem, 1.5vw, 1.45rem);
font-weight: 700;
letter-spacing: -0.01em;
transition:
background 120ms ease,
transform 90ms ease;
}
.start:hover {
background: #6cdcff;
}
.start:active {
transform: translateY(1px);
}
.start :global(.kbd) {
background: rgba(0, 0, 0, 0.2);
color: #04121a;
}
.launch-aside {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.2rem;
min-width: 0;
}
.launch-route {
font-size: 1.05rem;
font-weight: 600;
}
.launch-sub {
font-size: 0.88rem;
color: var(--ink-dim);
max-width: 34rem;
}
.launch-aside .btn {
margin-top: 0.3rem;
padding-left: 0;
}
.primary {
@@ -304,13 +533,16 @@
}
.effort {
flex: 1 1 0;
display: grid;
grid-template-columns: repeat(6, minmax(0, auto)) 1fr;
grid-template-columns: repeat(7, minmax(0, auto)) 1fr;
grid-template-rows: auto minmax(0, 1fr);
align-items: start;
gap: var(--gap);
padding: 0.9rem var(--edge) 0.4rem;
min-height: 0;
/* Nothing inside may be painted outside: see the note on `.ride`. */
overflow: hidden;
}
.spacer {
@@ -319,10 +551,16 @@
.charts {
grid-column: 1 / -1;
/* `align-items: start` above would otherwise leave this at its content
height — which is whatever size uPlot happened to pick — instead of the
height the track actually has. That is what let the canvas grow past the
bottom of the section. */
align-self: stretch;
display: grid;
grid-template-columns: 1.6fr 1fr;
gap: var(--gap);
min-height: 0;
overflow: hidden;
}
.chart {
@@ -330,6 +568,7 @@
grid-template-rows: auto 1fr;
gap: 0.15rem;
min-height: 0;
overflow: hidden;
}
@media (max-width: 1150px) {
+13 -2
View File
@@ -2,7 +2,7 @@
* Client-side view state. Everything here is either received from Rust or is
* purely presentational (which screen is showing, which toast is up).
*/
import { api, subscribe } from './bridge';
import { api, subscribe, type ControllerInput, type ControllerStatus } from './bridge';
import { History } from './history';
import type {
DeviceList,
@@ -29,6 +29,8 @@ class AppStore {
lastLap = $state<LapSummary | null>(null);
showHelp = $state(false);
showProfiles = $state(false);
/** Zwift Click link, so the UI can show battery and say when it dropped. */
controller = $state<ControllerStatus | null>(null);
/** Bumped on every snapshot so charts know to redraw without deep tracking. */
revision = $state(0);
@@ -39,7 +41,12 @@ class AppStore {
private lastElapsed = -1;
async init(): Promise<void> {
/**
* Controller input is routed by the caller, not here: `App.svelte` owns the
* keyboard map, and the Click must land on the *same* intents rather than a
* parallel set that can drift.
*/
async init(hooks: { onControllerInput?: (i: ControllerInput) => void } = {}): Promise<void> {
const [ride, devices, samples] = await Promise.all([
api.rideState(),
api.deviceList(),
@@ -67,6 +74,10 @@ class AppStore {
onInputAck: (a) => {
this.lastAck = { ...a, at: performance.now() };
},
onControllerInput: hooks.onControllerInput,
onControllerStatus: (s) => {
this.controller = s;
},
});
}
+39
View File
@@ -29,8 +29,38 @@ export const EVENTS = {
connection: 'devices://connection',
notice: 'app://notice',
inputAck: 'app://input-ack',
controllerInput: 'controller://input',
controllerStatus: 'controller://status',
} as const;
/** Button names as sent by `controller::button_name`. */
export type ControllerButton =
| 'left'
| 'up'
| 'right'
| 'down'
| 'a'
| 'b'
| 'y'
| 'z'
| 'minus'
| 'plus';
/** A press or release edge from a Zwift Click. Repeats while held are already
* filtered out in Rust, so every event here is a real edge. */
export type ControllerInput = {
button: ControllerButton;
pressed: boolean;
};
export type ControllerStatus = {
connected: boolean;
address: string | null;
name: string | null;
batteryPercent: number | null;
error: string | null;
};
/** True when running inside the Tauri shell rather than a bare browser. */
export const inTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
@@ -80,6 +110,11 @@ export const api = {
disconnect: (deviceId: string) => call<DeviceInfo>('disconnect_device', { deviceId }),
forget: (deviceId: string) => call<void>('forget_device', { deviceId }),
trainerControllable: () => call<boolean>('trainer_controllable'),
// controller (Zwift Click)
controllerStatus: () => call<ControllerStatus>('controller_status'),
connectController: (deviceId?: string) => call<void>('connect_controller', { deviceId }),
disconnectController: () => call<void>('disconnect_controller'),
};
type Handlers = {
@@ -89,6 +124,8 @@ type Handlers = {
onDevices?: (d: DeviceList) => void;
onNotice?: (n: Notice) => void;
onInputAck?: (a: InputAck) => void;
onControllerInput?: (i: ControllerInput) => void;
onControllerStatus?: (s: ControllerStatus) => void;
};
/** Subscribe to the whole event channel. Returns a single unsubscribe. */
@@ -104,5 +141,7 @@ export async function subscribe(h: Handlers): Promise<UnlistenFn> {
await add(EVENTS.devices, h.onDevices);
await add(EVENTS.notice, h.onNotice);
await add(EVENTS.inputAck, h.onInputAck);
await add(EVENTS.controllerInput, h.onControllerInput);
await add(EVENTS.controllerStatus, h.onControllerStatus);
return () => offs.forEach((off) => off());
}
+16
View File
@@ -101,6 +101,8 @@ export interface Derived {
normalisedPowerW: number | null;
avgCadenceRpm: number;
energyKj: number;
/** Estimated rider energy expenditure, kcal — not the same as `energyKj`. */
caloriesKcal: number;
}
/** What arrives on `ride://snapshot`. */
@@ -153,6 +155,18 @@ export interface LapSummary {
avgPowerW: number;
}
/** Trainer link state, mirrored from `src-tauri/src/trainer.rs`. */
export interface TrainerStatus {
state: ConnectionState;
/** FTMS control point acquired. Connected is NOT controllable (FR-9.3). */
controlAcquired: boolean;
address: string | null;
name: string | null;
error: string | null;
/** Connected, but no Indoor Bike Data for several seconds (FR-1.8). */
stale: boolean;
}
export interface RideState {
status: RideStatus;
mode: ControlMode;
@@ -164,7 +178,9 @@ export interface RideState {
lap: number;
laps: LapSummary[];
profile: ProfileView | null;
/** `'ftms'` for the real trainer, `'mock'` for the synthetic rider. */
source: string;
trainer: TrainerStatus;
}
export type DeviceKind = 'trainer' | 'clickLeft' | 'clickRight' | 'heartRate' | 'unknown';
+229
View File
@@ -0,0 +1,229 @@
Looking for address 94:05:bb:04:76:28...
Connected to 94:05:bb:04:76:28 (VANRYSEL-HT-2876)
No Zwift manufacturer data in the advertisement — this is not a controller,
or it was already connected when we found it.
=== Zwift service 00000001-19ca-4651-86e5-fa29dcdd09d1 (Zwift custom service) ===
char 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify))
properties: notify
char 00000003-19ca-4651-86e5-fa29dcdd09d1 (Zwift sync RX (write))
properties: write-without-response
char 00000004-19ca-4651-86e5-fa29dcdd09d1 (Zwift sync TX (indicate))
properties: indicate
Subscribed to 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify))
Subscribed to 00000004-19ca-4651-86e5-fa29dcdd09d1 (Zwift sync TX (indicate))
=== Handshake ===
-> RideOn + 00 09 (confirmed on Click v2) : 526964654f6e0009
[ 0.20s] #1 00000004-19ca-4651-86e5-fa29dcdd09d1 (Zwift sync TX (indicate)): 526964654f6e0200
RideOn reply, 8 byte(s) total
[ 0.98s] #2 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 0308001000180020002800
type: unknown (0x03)
varints: field 1 = 0, field 2 = 0, field 3 = 0, field 4 = 0, field 5 = 0
RideOn acknowledged — this is the handshake the device wants.
TASK-0 answered: the unencrypted path is open.
Listening for 120 s. Press the Click's paddles and D-pad; press Ctrl-C to stop.
[ 1.95s] #3 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 0308001000180020002800
type: unknown (0x03)
varints: field 1 = 0, field 2 = 0, field 3 = 0, field 4 = 0, field 5 = 0
[ 3.02s] #4 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 0308001000180020002800
type: unknown (0x03)
varints: field 1 = 0, field 2 = 0, field 3 = 0, field 4 = 0, field 5 = 0
[ 4.00s] #5 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 0308001000180020002800
type: unknown (0x03)
varints: field 1 = 0, field 2 = 0, field 3 = 0, field 4 = 0, field 5 = 0
[ 4.97s] #6 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 0308001000180020002800
type: unknown (0x03)
varints: field 1 = 0, field 2 = 0, field 3 = 0, field 4 = 0, field 5 = 0
[ 5.95s] #7 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 0308001000180020002800
type: unknown (0x03)
varints: field 1 = 0, field 2 = 0, field 3 = 0, field 4 = 0, field 5 = 0
[ 7.02s] #8 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 0308001000180020002800
type: unknown (0x03)
varints: field 1 = 0, field 2 = 0, field 3 = 0, field 4 = 0, field 5 = 0
[ 8.00s] #9 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 0308011000182c200128f709
type: unknown (0x03)
varints: field 1 = 1, field 2 = 0, field 3 = 44, field 4 = 1, field 5 = 1271
[ 8.97s] #10 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 03080510001865200528de16
type: unknown (0x03)
varints: field 1 = 5, field 2 = 0, field 3 = 101, field 4 = 5, field 5 = 2910
[ 9.95s] #11 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 03080a10001870200a289319
type: unknown (0x03)
varints: field 1 = 10, field 2 = 0, field 3 = 112, field 4 = 10, field 5 = 3219
[ 11.02s] #12 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 03080610001869200628d117
type: unknown (0x03)
varints: field 1 = 6, field 2 = 0, field 3 = 105, field 4 = 6, field 5 = 3025
[ 11.99s] #13 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 0308041000184e200428ca11
type: unknown (0x03)
varints: field 1 = 4, field 2 = 0, field 3 = 78, field 4 = 4, field 5 = 2250
[ 12.97s] #14 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 03080310001835200328fb0b
type: unknown (0x03)
varints: field 1 = 3, field 2 = 0, field 3 = 53, field 4 = 3, field 5 = 1531
[ 13.94s] #15 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 03080210001833200228c10b
type: unknown (0x03)
varints: field 1 = 2, field 2 = 0, field 3 = 51, field 4 = 2, field 5 = 1473
[ 15.01s] #16 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 0308001000180020002800
type: unknown (0x03)
varints: field 1 = 0, field 2 = 0, field 3 = 0, field 4 = 0, field 5 = 0
[ 15.99s] #17 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 0308001000180020002800
type: unknown (0x03)
varints: field 1 = 0, field 2 = 0, field 3 = 0, field 4 = 0, field 5 = 0
[ 16.97s] #18 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 0308001000180020002800
type: unknown (0x03)
varints: field 1 = 0, field 2 = 0, field 3 = 0, field 4 = 0, field 5 = 0
[ 18.04s] #19 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 0308001000180020002800
type: unknown (0x03)
varints: field 1 = 0, field 2 = 0, field 3 = 0, field 4 = 0, field 5 = 0
[ 19.01s] #20 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 0308001000180020002800
type: unknown (0x03)
varints: field 1 = 0, field 2 = 0, field 3 = 0, field 4 = 0, field 5 = 0
[ 19.99s] #21 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 0308001000180020002800
type: unknown (0x03)
varints: field 1 = 0, field 2 = 0, field 3 = 0, field 4 = 0, field 5 = 0
[ 20.96s] #22 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 0308001000180020002800
type: unknown (0x03)
varints: field 1 = 0, field 2 = 0, field 3 = 0, field 4 = 0, field 5 = 0
[ 22.04s] #23 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 0308001000180020002800
type: unknown (0x03)
varints: field 1 = 0, field 2 = 0, field 3 = 0, field 4 = 0, field 5 = 0
[ 23.01s] #24 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 0308001000180020002800
type: unknown (0x03)
varints: field 1 = 0, field 2 = 0, field 3 = 0, field 4 = 0, field 5 = 0
[ 23.99s] #25 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 0308001000180020002800
type: unknown (0x03)
varints: field 1 = 0, field 2 = 0, field 3 = 0, field 4 = 0, field 5 = 0
[ 24.96s] #26 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 0308001000180020002800
type: unknown (0x03)
varints: field 1 = 0, field 2 = 0, field 3 = 0, field 4 = 0, field 5 = 0
[ 26.03s] #27 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 03080310001854200328f612
type: unknown (0x03)
varints: field 1 = 3, field 2 = 0, field 3 = 84, field 4 = 3, field 5 = 2422
[ 27.01s] #28 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 030877100018c9032077289466
type: unknown (0x03)
varints: field 1 = 119, field 2 = 0, field 3 = 457, field 4 = 119, field 5 = 13076
[ 27.98s] #29 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 03088e01100018f603208e0128a170
type: unknown (0x03)
varints: field 1 = 142, field 2 = 0, field 3 = 502, field 4 = 142, field 5 = 14369
[ 29.06s] #30 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 030852100018fe03205228f371
type: unknown (0x03)
varints: field 1 = 82, field 2 = 0, field 3 = 510, field 4 = 82, field 5 = 14579
[ 30.03s] #31 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 03086d100018af04206d28ef7c
type: unknown (0x03)
varints: field 1 = 109, field 2 = 0, field 3 = 559, field 4 = 109, field 5 = 15983
[ 31.00s] #32 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 030873100018dd04207328858701
type: unknown (0x03)
varints: field 1 = 115, field 2 = 0, field 3 = 605, field 4 = 115, field 5 = 17285
[ 31.98s] #33 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 030879100018f104207928dc8b01
type: unknown (0x03)
varints: field 1 = 121, field 2 = 0, field 3 = 625, field 4 = 121, field 5 = 17884
[ 33.05s] #34 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 030879100018fd04207928a28e01
type: unknown (0x03)
varints: field 1 = 121, field 2 = 0, field 3 = 637, field 4 = 121, field 5 = 18210
[ 34.03s] #35 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 030884011000189d0520840128c59501
type: unknown (0x03)
varints: field 1 = 132, field 2 = 0, field 3 = 669, field 4 = 132, field 5 = 19141
[ 35.00s] #36 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 03087b100018fe04207b28cf8e01
type: unknown (0x03)
varints: field 1 = 123, field 2 = 0, field 3 = 638, field 4 = 123, field 5 = 18255
[ 35.98s] #37 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 030887011000189b0520870128fe9401
type: unknown (0x03)
varints: field 1 = 135, field 2 = 0, field 3 = 667, field 4 = 135, field 5 = 19070
[ 37.05s] #38 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 03088401100018900520840128cb9201
type: unknown (0x03)
varints: field 1 = 132, field 2 = 0, field 3 = 656, field 4 = 132, field 5 = 18763
[ 38.03s] #39 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 030880011000188d0520800128f39101
type: unknown (0x03)
varints: field 1 = 128, field 2 = 0, field 3 = 653, field 4 = 128, field 5 = 18675
[ 39.00s] #40 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 03087e1000188505207e288f9001
type: unknown (0x03)
varints: field 1 = 126, field 2 = 0, field 3 = 645, field 4 = 126, field 5 = 18447
[ 39.98s] #41 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 03087a100018f404207a289f8c01
type: unknown (0x03)
varints: field 1 = 122, field 2 = 0, field 3 = 628, field 4 = 122, field 5 = 17951
[ 41.05s] #42 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 030879100018f204207928ec8b01
type: unknown (0x03)
varints: field 1 = 121, field 2 = 0, field 3 = 626, field 4 = 121, field 5 = 17900
[ 42.02s] #43 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 030879100018f504207928b98c01
type: unknown (0x03)
varints: field 1 = 121, field 2 = 0, field 3 = 629, field 4 = 121, field 5 = 17977
[ 43.00s] #44 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 030876100018ec04207628be8a01
type: unknown (0x03)
varints: field 1 = 118, field 2 = 0, field 3 = 620, field 4 = 118, field 5 = 17726
[ 43.97s] #45 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 03086e100018d504206e28ba8501
type: unknown (0x03)
varints: field 1 = 110, field 2 = 0, field 3 = 597, field 4 = 110, field 5 = 17082
[ 45.05s] #46 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 030869100018cd04206928be8301
type: unknown (0x03)
varints: field 1 = 105, field 2 = 0, field 3 = 589, field 4 = 105, field 5 = 16830
[ 46.02s] #47 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 03086c100018d204206c28d98401
type: unknown (0x03)
varints: field 1 = 108, field 2 = 0, field 3 = 594, field 4 = 108, field 5 = 16985
[ 46.99s] #48 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 030874100018f404207428a88c01
type: unknown (0x03)
varints: field 1 = 116, field 2 = 0, field 3 = 628, field 4 = 116, field 5 = 17960
[ 48.07s] #49 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 0308d201100018b60620d20128cdb701
type: unknown (0x03)
varints: field 1 = 210, field 2 = 0, field 3 = 822, field 4 = 210, field 5 = 23501
[ 49.04s] #50 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 0308c803100018930820c80328f3e801
type: unknown (0x03)
varints: field 1 = 456, field 2 = 0, field 3 = 1043, field 4 = 456, field 5 = 29811
[ 50.02s] #51 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 0308c403100018c60820c403289ff401
type: unknown (0x03)
varints: field 1 = 452, field 2 = 0, field 3 = 1094, field 4 = 452, field 5 = 31263
[ 50.99s] #52 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 03088503100018ad0820850328d6ee01
type: unknown (0x03)
varints: field 1 = 389, field 2 = 0, field 3 = 1069, field 4 = 389, field 5 = 30550
[ 52.07s] #53 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 0308dc02100018840820dc0228c1e501
type: unknown (0x03)
varints: field 1 = 348, field 2 = 0, field 3 = 1028, field 4 = 348, field 5 = 29377
[ 53.04s] #54 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 0308d4021000188e0820d40228f0e701
type: unknown (0x03)
varints: field 1 = 340, field 2 = 0, field 3 = 1038, field 4 = 340, field 5 = 29680
[ 54.02s] #55 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 0308d002100018ed0720d00228bce001
type: unknown (0x03)
varints: field 1 = 336, field 2 = 0, field 3 = 1005, field 4 = 336, field 5 = 28732
[ 54.99s] #56 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 0308b602100018cf0720b60228d5d901
type: unknown (0x03)
varints: field 1 = 310, field 2 = 0, field 3 = 975, field 4 = 310, field 5 = 27861
[ 56.06s] #57 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 0308a302100018aa0720a30228bed101
type: unknown (0x03)
varints: field 1 = 291, field 2 = 0, field 3 = 938, field 4 = 291, field 5 = 26814
[ 57.04s] #58 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 0308a9011000188f0620a90128e7ae01
type: unknown (0x03)
varints: field 1 = 169, field 2 = 0, field 3 = 783, field 4 = 169, field 5 = 22375
[ 58.01s] #59 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 03083e100018f004203e28b18b01
type: unknown (0x03)
varints: field 1 = 62, field 2 = 0, field 3 = 624, field 4 = 62, field 5 = 17841
[ 58.99s] #60 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 030827100018ef03202728d66e
type: unknown (0x03)
varints: field 1 = 39, field 2 = 0, field 3 = 495, field 4 = 39, field 5 = 14166
[ 60.06s] #61 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 03081b1000188903201b28e457
type: unknown (0x03)
varints: field 1 = 27, field 2 = 0, field 3 = 393, field 4 = 27, field 5 = 11236
[ 61.03s] #62 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 030814100018b0022014288044
type: unknown (0x03)
varints: field 1 = 20, field 2 = 0, field 3 = 304, field 4 = 20, field 5 = 8704
[ 62.01s] #63 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 030812100018e201201228ce32
type: unknown (0x03)
varints: field 1 = 18, field 2 = 0, field 3 = 226, field 4 = 18, field 5 = 6478
[ 62.99s] #64 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 0308121000189d012012289f23
type: unknown (0x03)
varints: field 1 = 18, field 2 = 0, field 3 = 157, field 4 = 18, field 5 = 4511
[ 64.06s] #65 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 03080910001865200928db16
type: unknown (0x03)
varints: field 1 = 9, field 2 = 0, field 3 = 101, field 4 = 9, field 5 = 2907
[ 65.03s] #66 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 030804100018432004288c0f
type: unknown (0x03)
varints: field 1 = 4, field 2 = 0, field 3 = 67, field 4 = 4, field 5 = 1932
[ 66.01s] #67 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 03080310001833200328c80b
type: unknown (0x03)
varints: field 1 = 3, field 2 = 0, field 3 = 51, field 4 = 3, field 5 = 1480
[ 66.98s] #68 00000002-19ca-4651-86e5-fa29dcdd09d1 (Zwift async (notify)): 0308011000180020012800
type: unknown (0x03)
varints: field 1 = 1, field 2 = 0, field 3 = 0, field 4 = 1, field 5 = 0