Scaffold workspace, shared types and requirements spec

Cargo workspace with core/ble/fit/probe crates. crates/core/src/types.rs
is the fixed contract between the BLE layer, ride engine and UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 13:11:32 +02:00
co-authored by Claude Opus 5
commit eb216dd9e6
21 changed files with 2910 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
# Rust
/target
**/*.rs.bk
# Node
node_modules/
dist/
.vite/
# Tauri
src-tauri/target/
src-tauri/gen/
# Editors / OS
.DS_Store
*.swp
# Ride data
*.fit
/rides/
Generated
+1335
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
[workspace]
resolver = "2"
members = ["crates/core", "crates/ble", "crates/fit", "crates/probe"]
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "MIT"
repository = "https://github.com/dtourolle/BikeControl"
[workspace.dependencies]
bikecontrol-core = { path = "crates/core" }
bikecontrol-ble = { path = "crates/ble" }
bikecontrol-fit = { path = "crates/fit" }
anyhow = "1"
thiserror = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml_ng = "0.10"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
btleplug = "0.11"
futures = "0.3"
uuid = "1"
roxmltree = "0.20"
chrono = "0.4"
[profile.release]
lto = true
opt-level = 3
+675
View File
@@ -0,0 +1,675 @@
# BikeControl — Requirements Specification
**Status:** Draft v0.4
**Date:** 2026-08-05
**Target hardware:** Van Rysel D100 trainer + Zwift Cog + **Zwift Click v2**
> **v0.4 changes — the input decision is reversed.** Further research found that the Click
> v2 "unlock" is **performed by the rider in the free Zwift app**, not by the consuming
> application, and that it then works with *any* third-party app for ~24 h (§2.3). Combined
> with three open-source implementations of the Zwift protocol — one **GPL-3.0**, one
> **MIT** (§3.43.6) — **the app talks to the Click v2 directly over BLE.** v0.3's
> OpenBikeControl bridge is dropped: **there is no fallback input path.** New risk: the
> **left pod**, where third-party support is weakest, now carries all gradient and route
> control with nothing behind it (RISK-9).
---
## 1. Overview
BikeControl is a desktop-first (Android-later) application that connects to a Van Rysel
D100 smart trainer and a handlebar controller over Bluetooth Low Energy, and provides:
- **Virtual shifting** on the single-cog drivetrain, via the Click's shift paddles.
- **Handlebar gradient control**, via the Click's D-pad.
- **Gradient-adaptive routes** driven from GPX or hand-authored profiles.
- **Synthetic waveform profiles** — sine, square, ramp — applied to resistance, gradient
or target power.
- **Ride recording** exported as a FIT file.
- **A GUI** for connection management and live telemetry visualisation.
### 1.1 Goals
| ID | Goal |
|----|------|
| G-1 | Ride a gradient profile end-to-end without touching the computer once started |
| G-2 | Control gearing and gradient entirely from the bars |
| G-3 | Produce a FIT file that imports cleanly into Strava / Garmin Connect |
| G-4 | Run on Linux desktop today; port to Android without rewriting the core |
| G-5 | Fail safe — never leave the trainer at high resistance after a fault |
| G-6 | Prefer documented, openly-licensed protocols; keep any proprietary protocol handling isolated and replaceable |
### 1.2 Non-goals
- Multiplayer, social features, or any network service.
- 3D world rendering or avatars.
- ANT+ support (§10.1).
- **Implementing the Click v2 unlock ourselves.** The rider performs it in the free Zwift
app (§2.3); we only need to speak the protocol afterwards.
- Acting as a bridge for *other* training apps — that is what the existing BikeControl
app does, and this project does not duplicate it.
- Any secondary input protocol. The Click v2 client is the only input path.
---
## 2. Hardware & Operating Assumptions
### 2.1 Van Rysel D100 ✅
**FTMS support confirmed** by a working MIT-licensed implementation built specifically for
this trainer (§3.2).
| Property | Value | Confidence |
|----------|-------|------------|
| Bluetooth profile | FTMS (`0x1826`) | **Confirmed** |
| Target power (`0x05`) | Supported; reference clamps 100600 W | **Confirmed** |
| Target resistance (`0x04`) | Supported, sint16; reference caps at 100 | **Confirmed** |
| Target inclination (`0x03`) | Implemented in the reference as sint16 | **Confirmed** |
| Sim mode (`0x11`) | Not used by the reference | **Unconfirmed — TASK-1** |
| Concurrent BLE hosts | Assume **one** | Assumed |
> **A-1:** The reference drives grade via `SetTargetInclination` (`0x03`), not
> `SetIndoorBikeSimulationParameters` (`0x11`). TASK-1 resolves whether `0x11` works. Low
> impact either way, because the app owns the physics (FR-7.1).
### 2.2 Zwift Cog
A single 14T cog — **no mechanical gears**. This is why virtual shifting (§5.4) is a core
requirement, not a nicety: without it the rider has exactly one gear.
### 2.3 Zwift Click v2 ⚠
The unit is a **v2** (two pods: navigation D-pad on the left, lettered face buttons on the
right, a shift paddle under each). This is the harder variant, and the difference is not
merely one of degree.
**The unlock is performed by the rider, not by the app.** This is the key finding of v0.4
and it changes everything. The Click v2's encryption context times out roughly a minute
after it leaves a Zwift session; refreshing it requires the official Zwift app. But once
refreshed, **the device works with *any* third-party app for ~24 hours**.
The rider's procedure — **free, no paid subscription required**:
1. Open the Zwift app (desktop or mobile) and log in.
2. Go to the device pairing screen; pair the Click.
3. Keep it connected 1030 seconds while pressing a button.
4. Close Zwift completely.
> **A-2:** BikeControl 6.0.0 (June 2026) added a keep-alive that removes the daily unlock
> entirely — but only for the **right** controller, only for paid Pro users, and the
> 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
Established from three independent open-source implementations (§3.43.6).
| 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) |
**Encryption.** The handshake performs a key exchange and messages are then encrypted:
| Stage | Mechanism |
|-------|-----------|
| Key agreement | **ECDH on NIST P-256** (`prime256v1`); each side sends a public key |
| Key derivation | **HKDF** → 36 bytes: bytes 031 = AES key, bytes 3235 = IV |
| 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.
> **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.
### 2.4 Environmental assumptions
- **A-3:** BLE peripherals accept a single central connection. Only one host may hold the
trainer, and only one may hold the Click.
- **A-4:** The trainer wakes on pedalling; the Click wakes on button press. Either may be
absent from a scan until woken — the UI must say so rather than report "not found".
---
## 3. Prior Art, Reuse and the Input Decision
### 3.1 `OpenBikeControl/bikecontrol` — Zwift device reference ⚠
Flutter/Dart, 589★, actively maintained. A *bridge* app that translates controller input
into actions for other training apps. Not used as a component here, but its public source
documents Zwift device discrimination (manufacturer-data type bytes, v1 vs v2 response
codes) and the shape of the v2 unlock.
> **⚠ Licensing.** Current versions are a custom **Non-Commercial** licence (©
> OpenBikeControl UG): personal/educational use only, source-only redistribution under the
> same terms, no commercial use, no marketplace distribution, and the grant is
> **revocable**. Versions before the `gpl3` tag were **GPL-3.0** (copyleft). **No code is
> copied from it** — treat it as a map, and confirm bytes against the device.
### 3.2 `obostjancic/smart-trainer-control` — D100 FTMS reference ✅
React/TypeScript, Web Bluetooth, **MIT**. Literally *"Control a Van Rysel D100 smart
trainer from your browser"*. Working FTMS client, control-point encoders, and FIT and TCX
writers with tests.
**MIT means this logic can be ported freely, with attribution.** It pre-solves most of
TASK-1 and de-risks the FIT writer.
### 3.3 `OpenBikeControl/openbikecontrol-protocol` — considered, not used
An MIT open standard (service `d273f680-…`) for controllers to drive trainer apps, with
Python references for both sides. v0.3 proposed consuming it via a bridge process. **That
approach is dropped** — it required a second app running during every ride. Recorded here
only so the decision is not silently revisited.
### 3.4 `cagnulein/qdomyos-zwift` (QZ) — GPL-3.0 Zwift protocol implementation ⭐
C++/Qt, 826★, **GPL-3.0**. `src/zwift_play/` contains a complete working implementation:
`zapCrypto.h` (ECDH/HKDF/AES-CCM), `localKeyProvider.h` (P-256 keygen), `zapBleUuids.h`,
`zapConstants.h`, `abstractZapDevice.h` (handshake), and `zwiftclickremote.cpp`.
**This is the reference that makes direct Click v2 support tractable.** It is the source of
the crypto details in §2.3.1.
> **Licensing:** GPL-3.0 is copyleft. **Porting this code makes BikeControl GPL-3.0.** The
> underlying algorithm — ECDH P-256 + HKDF + AES-256-CCM — is standard cryptography and not
> itself copyrightable, so a clean implementation from the *documented* protocol (§2.3.1) is
> unencumbered. See OQ-9.
### 3.5 `ajchellew/zwiftplay` — the original reverse-engineering ⚠
Kotlin/Android + a C# Windows console app. The origin of most public knowledge here,
including the packet captures, the `RideOn` handshake, and the fourth (`…0006-…`)
characteristic.
> **⚠ No LICENSE file — all rights reserved.** Excellent *documentation*, but **no code may
> be copied from it.** Its README is the citable protocol description.
### 3.6 `jat255/zwift_click_handling` — MIT, Python, Linux/BlueZ ✅
A small **MIT** script using `bleak` on Linux that connects to a Click and logs button
press/release and battery level. Notably it connects **without encryption** and still
receives events (A-3), and it carries the protocol constants in reusable form.
**MIT and already proven on Linux/BlueZ** — the natural basis for the `probe` tool.
### 3.7 Decision: direct Click v2, no fallback
v0.3 routed all input through a bridge process because the unlock looked insurmountable.
The research above shows it is not: **the rider performs the unlock in the free Zwift app,
and the protocol afterwards is fully documented across three open implementations.**
```
Zwift Click v2 ──BLE, ECDH P-256 + HKDF + AES-256-CCM──► This app
(rider unlocks daily in the free Zwift app)
```
| | |
|---|---|
| **No second app during rides** | The bridge's real cost was a process running every session. A once-daily 30-second unlock is a far smaller tax. |
| **The protocol is documented** | §2.3.1 is a complete spec, corroborated by three independent implementations. |
| **Standard crypto, pure Rust** | `p256`, `hkdf`, `sha2`, `aes`, `ccm` — no C dependency, no bespoke cryptography. |
| **Lower latency** | No intermediate hop in the NFR-1 budget. |
| **One code path** | No input abstraction to maintain, no divergence between paths. |
**Accepted consequence.** There is no second way in. If a Zwift firmware update breaks the
client, or the left pod proves unreliable (RISK-9), controller input is lost until the
client is fixed — the keyboard and on-screen controls (FR-3.19) are the only stopgap. This
is a deliberate trade of resilience for simplicity.
> **OQ-9 — licensing.** Porting QZ's crypto directly makes this project **GPL-3.0**. Writing
> it from the documented algorithm keeps the licence open. Which do you want? For a personal
> project GPL-3.0 costs nothing.
---
## 4. Technology Stack
**Tauri v2**, Rust core + web frontend. Confirmed; we port rather than import.
| Layer | Choice | Notes |
|-------|--------|-------|
| Shell | Tauri v2 | Desktop + Android from one codebase |
| Core | Rust | BLE, physics, profiles, gearing, FIT |
| BLE | `btleplug` via `tauri-plugin-blec` | blec supplies Android JNI/permission plumbing |
| Crypto | `p256`, `hkdf`, `sha2`, `aes`, `ccm` | Click v2 session (§2.3.1); pure Rust, no OpenSSL |
| Frontend | Web — Svelte recommended | Connection UI, telemetry |
| Charts | `uPlot` | Canvas, built for streaming series |
| FIT | Ported from §3.2 (MIT) | See RISK-3 |
**Architectural constraint.** The control loop lives in **Rust, not JavaScript**.
`tauri-plugin-blec` is designed to expose BLE to the frontend; we use its Rust-side API and
keep all device I/O, physics and state in the core. The frontend renders telemetry pushed
over Tauri events and issues intents as commands. This keeps the safety-critical path (§7)
independent of the webview and the core testable without a UI.
```
bikecontrol/
├── crates/
│ ├── core/ # physics, profiles, gearing, state machine — no BLE, no UI
│ ├── ble/ # FTMS client, Zwift Click v2 client (handshake + crypto)
│ ├── fit/ # FIT encoder (ported from §3.2)
│ └── probe/ # CLI for protocol discovery (Phase 0)
├── src-tauri/ # Tauri shell, commands, event bridge
└── ui/ # web frontend
```
---
## 5. Functional Requirements
### 5.1 Device discovery and connection (FR-1)
| ID | Requirement | Priority |
|----|-------------|----------|
| FR-1.1 | Scan for BLE peripherals; list name, address, RSSI, advertised services | Must |
| FR-1.2 | Identify trainers by FTMS service UUID and Click pods by the Zwift custom service UUID plus manufacturer-data type byte | Must |
| FR-1.3 | Connect to trainer and controller independently; either may connect first | Must |
| FR-1.4 | Track the left and right pods as separate connections, since each is an independent peripheral | Must |
| FR-1.5 | Remember paired devices and auto-connect on launch | Should |
| FR-1.6 | Auto-reconnect on unexpected disconnect, with backoff, without ending the ride | Must |
| 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 |
### 5.2 Trainer control (FR-2)
| ID | Requirement | Priority |
|----|-------------|----------|
| FR-2.1 | Acquire FTMS control (`0x00`) before issuing commands | Must |
| FR-2.2 | Subscribe to Indoor Bike Data (`0x2AD2`); decode all present fields per the flags bitfield | Must |
| FR-2.3 | Set gradient via `SetTargetInclination` (`0x03`), or `0x11` if TASK-1 confirms support | Must |
| FR-2.4 | Set resistance via `SetTargetResistanceLevel` (`0x04`) | Must |
| FR-2.5 | Set target power via `SetTargetPower` (`0x05`) — needed for ERG waveforms | Must |
| FR-2.6 | Read and respect `Fitness Machine Feature` (`0x2ACC`) and `Supported Resistance Level Range` (`0x2AD6`); never send out-of-range values | Must |
| FR-2.7 | Handle control-point indications including error responses, not fire-and-forget | Must |
| FR-2.8 | Rate-limit control writes (≤4 Hz target) | Must |
**Decoding note (FR-2.2):** Indoor Bike Data is variable-length, determined by a leading
16-bit flags field. **Bit 0 is inverted** — instantaneous speed is present when the bit is
*clear*. Fields must be consumed strictly in specification order.
### 5.3 Controller input (FR-3)
Input comes from the **Zwift Click v2 over BLE, directly** (§2.3.1). There is no bridge and
no secondary protocol path.
**Direct Click v2 client**
| ID | Requirement | Priority |
|----|-------------|----------|
| FR-3.1 | Discover Click pods by Zwift custom service UUID plus manufacturer data; identify left vs right pod | Must |
| FR-3.2 | Perform the `RideOn` handshake and establish the encrypted session — ECDH P-256 → HKDF → AES-256-CCM (§2.3.1) | Must |
| FR-3.3 | Attempt the **unencrypted** path first (A-3) and fall back to encrypted — it is far simpler if the v2 permits it | Should |
| FR-3.4 | Connect **both pods concurrently**; each is an independent BLE peripheral | Must |
| FR-3.5 | Decode controller notifications into press/release events for every button on both pods | Must |
| FR-3.6 | Handle keepalive/empty messages (`0x15`) and maintain the session | Must |
| FR-3.7 | Decode battery level (`0x19`) per pod and warn when low | Should |
**Unlock handling — the app guides, the rider performs**
The unlock happens in the Zwift app (§2.3). This application never performs it, never asks
for Zwift credentials, and never talks to Zwift's servers. It detects the state and walks
the rider through the steps.
| ID | Requirement | Priority |
|----|-------------|----------|
| FR-3.8 | Detect the locked failure mode — notifications stop roughly a minute after connecting — and distinguish it from an ordinary dropout | Must |
| FR-3.9 | Track the last known-good unlock per pod and show remaining validity against the ~24 h window | Must |
| FR-3.10 | **Guide** the rider through the unlock with explicit ordered steps (open Zwift → log in → pairing screen → pair the Click → hold a button 1030 s → close Zwift → return here) | Must |
| FR-3.11 | State plainly that no paid Zwift subscription is required | Should |
| FR-3.12 | Warn before a ride starts when the unlock is stale, expiring soon, or unknown | Must |
| FR-3.13 | Detect and confirm success automatically once events resume, closing the guidance without the rider having to declare it worked | Should |
| FR-3.14 | Offer "mark as unlocked" for riders who unlocked outside the app | Could |
| FR-3.15 | Never store Zwift credentials or contact Zwift services | Must |
**Input handling**
| ID | Requirement | Priority |
|----|-------------|----------|
| FR-3.16 | Debounce input; support press, release and hold-to-repeat | Must |
| FR-3.17 | All bindings user-configurable; the mapping below is the default | Should |
| FR-3.18 | Hold on D-pad up/down repeats the gradient step at a fixed rate | Should |
| FR-3.19 | Keyboard and on-screen controls mirror every action — needed to develop and test the app before the Click client works, and to keep a ride going if a pod dies mid-session | Must |
**Button mapping.** Per OQ-1: shift paddles shift, D-pad handles gradient.
| Click v2 control | Pod | Action |
|------------------|-----|--------|
| Shift paddle `+` | Right | Virtual gear **up** |
| Shift paddle `` | Left | Virtual gear **down** |
| D-pad up | Left | Gradient **+0.5%** |
| D-pad down | Left | Gradient **0.5%** |
| D-pad left | Left | **Previous** route / profile |
| D-pad right | Left | **Next** route / profile |
| Face button A | Right | Cycle control mode (§5.4) |
| Face button Z | Right | Reset gradient offset to zero |
| Face button B | Right | Pause / resume ride |
| Face button Y | Right | Insert lap marker |
> **RISK-9 applies here.** Every gradient and route control sits on the **left** pod, which
> is where third-party support is weakest (§10). With no bridge fallback, the left pod is a
> single point of failure for gradient control — TASK-0 must prove it works before this
> mapping is committed to.
### 5.4 Control modes (FR-4)
| ID | Mode | Behaviour | Priority |
|----|------|-----------|----------|
| FR-4.1 | **Virtual gearing** | Paddles shift a configurable virtual cassette; resistance follows gear × terrain | **Must** |
| FR-4.2 | **Manual grade** | D-pad adjusts simulated gradient in ±0.5% steps | Must |
| FR-4.3 | **Resistance** | Buttons step trainer resistance directly, ignoring physics | Must |
| FR-4.4 | **Route** | Gradient driven by route profile at current distance; paddles shift | Must |
| FR-4.5 | **Waveform** | Gradient/resistance/power driven by a synthetic profile (§5.6) | Must |
| FR-4.6 | **ERG** | Trainer holds a fixed target power | Should |
Gearing and gradient are **simultaneously active**, not alternatives — the paddles always
shift while the D-pad always trims gradient. "Mode" selects where the *base* gradient comes
from (manual, route, or waveform).
**Virtual shifting design (FR-4.1).** No mechanical shifting exists, and FTMS has no
virtual-shifting opcode. Approach:
1. Define a virtual cassette: N gears (default ~24), ratios configurable.
2. The app owns the physics (FR-7.1), so it knows the wheel force required for the current
virtual speed and gradient.
3. The selected gear sets the cadence needed to hold that speed:
`cadence = v / (gear_ratio × wheel_circumference)`.
4. The app sets trainer resistance so effort at that cadence matches the physics —
effectively `resistance = f(gradient, speed, gear_ratio)`.
5. Mapping that target onto a D100 resistance level needs **empirical calibration** of the
trainer's resistance curve (TASK-3).
| ID | Requirement | Priority |
|----|-------------|----------|
| FR-4.1.1 | Configurable gear count and ratios, with sensible defaults | Must |
| FR-4.1.2 | Display current gear prominently, with clear feedback on each shift | Must |
| FR-4.1.3 | Clamp at top and bottom gear; never wrap around | Must |
| FR-4.1.4 | Shift response ≤250 ms end-to-end (NFR-1) | Must |
| FR-4.1.5 | Persist gear selection across a reconnect | Should |
### 5.5 Routes (FR-5)
| ID | Requirement | Priority |
|----|-------------|----------|
| FR-5.1 | Import GPX; derive a distance/elevation profile | Must |
| FR-5.2 | Smooth GPX elevation before differentiating into gradients — raw GPS elevation is far too noisy to send to a trainer | Must |
| FR-5.3 | Clamp derived gradients to a configurable range (default 10%…+15%) | Must |
| FR-5.4 | Hand-authored routes as an ordered segment list in a human-editable file | Must |
| FR-5.5 | Interpolate between profile points so grade changes are continuous, not stepped | Must |
| FR-5.6 | Support looping, and support finishing at the end | Should |
| FR-5.7 | Show distance covered, remaining, and elevation profile with current position | Must |
| FR-5.8 | Cycle routes from the D-pad mid-ride (`0x12`/`0x13`) | Must |
### 5.6 Synthetic waveform profiles (FR-6)
| ID | Requirement | Priority |
|----|-------------|----------|
| FR-6.1 | Waveform types: **sine, square, triangle, sawtooth, ramp, constant** | Must |
| FR-6.2 | Apply to any of three channels: **gradient (%)**, **resistance level**, **target power (W)** | Must |
| FR-6.3 | Parameterise by amplitude, midpoint, period, phase, and duration or repeat count | Must |
| FR-6.4 | Support **time-based** and **distance-based** periods | Should |
| FR-6.5 | Compose into a sequence of blocks — warm-up ramp, sine intervals, cool-down | Must |
| FR-6.6 | Share a file format with hand-authored routes, so a profile may mix terrain and waveform blocks | Should |
| FR-6.7 | Render a preview chart before the ride; show position within it during | Must |
| FR-6.8 | Clamp generated values to the safe range at transmission (SAF-3) | Must |
| FR-6.9 | Smooth transitions between blocks so the trainer does not step discontinuously | Should |
**Illustrative format** (TBD):
```yaml
name: "Over-unders + hill repeats"
blocks:
- { type: ramp, channel: power, from_w: 100, to_w: 200, duration_s: 600 }
- { type: sine, channel: power, midpoint_w: 240, amplitude_w: 40,
period_s: 120, repeats: 8 }
- { type: segments, channel: gradient, loop: true, segments: [
{ distance_m: 800, gradient_pct: 6.5 },
{ distance_m: 400, gradient_pct: -3.0 } ] }
- { type: constant, channel: power, watts: 120, duration_s: 300 }
```
### 5.7 Ride engine (FR-7)
| ID | Requirement | Priority |
|----|-------------|----------|
| FR-7.1 | Compute virtual speed from measured power, gradient and mass — the app owns the physics rather than trusting the trainer's reported speed | Must |
| FR-7.2 | Integrate speed into distance, which drives route position | Must |
| FR-7.3 | Model inertia so speed changes feel natural rather than snapping to steady state | Must |
| FR-7.4 | Expose rider mass, bike mass, Crr, CdA and wheel circumference as configurable | Must |
| FR-7.5 | Use the trainer's reported speed as diagnostic/fallback only | Should |
| FR-7.6 | Track elapsed time, moving time, elevation gained, average and normalised power | Should |
```
F_propulsive = (P_measured × drivetrain_efficiency) / max(v, v_min)
F_gravity = m × g × sin(atan(gradient))
F_rolling = m × g × Crr × cos(atan(gradient))
F_aero = ½ × ρ × CdA × v²
a = (F_propulsive F_gravity F_rolling F_aero) / m
v += a × Δt (clamped at ≥ 0)
```
Owning the physics is a prerequisite for virtual shifting, makes behaviour reproducible in
tests, and removes dependence on the trainer's internal mass assumptions.
### 5.8 Recording and export (FR-8)
| ID | Requirement | Priority |
|----|-------------|----------|
| FR-8.1 | Record a 1 Hz series: timestamp, power, cadence, speed, distance, gradient, gear, mode | Must |
| FR-8.2 | Export a valid FIT activity (file_id, session, lap, record, activity; correct CRC) | Must |
| FR-8.3 | Verify exported FIT files import into Strava and Garmin Connect | Must |
| FR-8.4 | Persist the raw series so a FIT can be regenerated after a crash | Must |
| FR-8.5 | Continue recording across a BLE dropout, marking the gap rather than aborting | Must |
| FR-8.6 | Write incrementally — a crash must not lose the session | Must |
| FR-8.7 | Record lap markers triggered from the controller (`0x35`) | Should |
> Only FIT export was requested. In-app ride *history* is not in scope for v1 — the FIT
> file is the ride artifact. FR-8.4's raw log is crash safety, not a history feature.
### 5.9 GUI (FR-9)
**Connection screen**
| ID | Requirement | Priority |
|----|-------------|----------|
| FR-9.1 | Live device list with signal strength and identified type | Must |
| FR-9.2 | Per-device connect / disconnect / forget, with clear state and error text | Must |
| FR-9.3 | Show FTMS control acquisition separately from BLE connection — connected ≠ controllable | Must |
| FR-9.4 | Show per-pod connection and unlock state, with a **guided walkthrough** of the Zwift unlock (FR-3.10) launched from here and from any locked-state warning | Must |
**Ride screen**
| ID | Requirement | Priority |
|----|-------------|----------|
| FR-9.5 | Large, legible readouts (power, cadence, speed, gradient, gear, elapsed) readable at ~1 m | Must |
| FR-9.6 | Live streaming charts of power and gradient/target | Must |
| FR-9.7 | Route elevation profile or waveform preview with current position marked | Must |
| FR-9.8 | Prominent display of active mode, current gear, and current target | Must |
| FR-9.9 | Visible feedback on every button press, so the rider knows input registered | Must |
| 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 |
**Post-ride**
| ID | Requirement | Priority |
|----|-------------|----------|
| FR-9.13 | Summary: duration, distance, elevation, average/max power, average cadence | Must |
| FR-9.14 | Save FIT to a chosen location, with the path confirmed | Must |
---
## 6. Non-Functional Requirements
| ID | Requirement |
|----|-------------|
| NFR-1 | **Input latency** — button press to trainer resistance change ≤ 250 ms |
| NFR-2 | **Telemetry** — process trainer notifications at native rate (14 Hz) without backlog |
| NFR-3 | **UI** — charts stay smooth for a 2-hour ride without unbounded memory growth |
| NFR-4 | **Resilience** — no BLE dropout, malformed packet or missing characteristic may crash the app |
| NFR-5 | **Portability**`core` and `fit` compile for Android with no platform-specific code |
| 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 |
---
## 7. Safety Requirements
| ID | Requirement |
|----|-------------|
| SAF-1 | On controller disconnect, hold the last target — never continue applying pending increments |
| SAF-2 | On app exit, crash, or ride end, reset the trainer to 0% grade / minimum resistance |
| SAF-3 | Clamp gradient, resistance and target power to configurable safe ranges **at the point of transmission**, regardless of source |
| SAF-4 | If the trainer stops acknowledging control-point writes, stop sending and alert the rider |
| 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 |
---
## 8. Architecture
```
┌──────────────────────────────────────────────────────┐
│ Frontend (webview) │
│ Connection manager · Gauges · uPlot · Profile editor│
└──────────────▲────────────────────────┬──────────────┘
events │ │ commands
┌──────────────┴────────────────────────▼──────────────┐
│ Rust core │
│ ┌──────────┐ ┌──────────┐ ┌─────────┐ ┌──────────┐ │
│ │ Ride │ │ Profile │ │ Virtual │ │ Recorder │ │
│ │ engine │◄┤ engine │ │ gearing │ │ → FIT │ │
│ │(physics) │ │(GPX/wave)│ └────┬────┘ └──────────┘ │
│ └────▲─────┘ └──────────┘ │ target │
│ ┌────┴───────────┐ ┌──────────▼──────┐ │
│ │ FTMS client │ │ Click v2 client │ │
│ │ (trainer, BLE) │ │ (BLE + crypto) │ │
│ └────────▲───────┘ └────────▲────────┘ │
│ │ btleplug / blec │ │
└───────────┼───────────────────┼──────────────────────┘
│ │ ECDH P-256 → HKDF
│ │ → AES-256-CCM
┌──────┴──────┐ ┌──────┴──────────────┐
│ D100 │ │ Click v2 L + R pods │
└─────────────┘ └─────────────────────┘
daily unlock performed
by the rider in Zwift
```
---
## 9. Delivery Plan
### Phase 0 — D100 protocol discovery *(blocking for Phase 1)*
| Task | Description |
|------|-------------|
| **TASK-1** | `probe` against the D100: enumerate services, dump `Fitness Machine Feature` and `Supported Resistance Level Range`, log decoded Indoor Bike Data, resolve whether `0x11` works (A-1) |
| **TASK-2** | Write control commands to the D100; confirm physical resistance change |
**Exit criteria:** telemetry decodes correctly and a written command produces a felt
resistance change.
### Phase 0b — Deferred discovery *(blocking for Phase 3 only)*
| Task | Description |
|------|-------------|
| **TASK-0** | **Prove the Click v2 — the riskiest thing in the project.** Unlock both pods in the Zwift app, then with `probe`: (a) try the **unencrypted** path (A-3); (b) if that fails, do the full `RideOn` + ECDH/HKDF/AES-CCM handshake; (c) log raw and decrypted frames against a known button sequence; (d) **confirm every button on *both* pods registers** (RISK-9); (e) measure how long events survive without a fresh unlock. Prototyping in Python against §3.6's MIT code is legitimate — the goal is knowledge, not shipped code |
| **TASK-3** | **Characterise the D100 resistance curve** — required to map virtual gears onto it (FR-4.1) |
| **TASK-4** | Android BLE spike: minimal Tauri v2 Android build scanning via `blec` (RISK-1) |
> If TASK-0(d) fails on the left pod, resolve OQ-10 before building the button mapping.
### Phase 1 — D100 + GUI + profiles ⭐ *(current focus)*
**The Click is deferred to Phase 3.** All control in this phase is on-screen; the app is
fully rideable from the keyboard and mouse before any Zwift protocol work begins. This
removes the only component with unknown protocol risk from the critical path.
FR-1 (trainer only), FR-2, FR-4.2/4.3/4.4/4.5, FR-5 (GPX), FR-6 (waveforms), FR-7
(physics), FR-3.19 (keyboard/on-screen), FR-9.19.3, 9.59.8, 9.109.12, §7 safety.
*Milestone: load a GPX or a sine-wave profile, ride it, and control gradient from the GUI.*
### Phase 2 — Recording
FR-8 (recording, FIT, crash safety), FR-9.13/9.14.
*Milestone: a ride lands in Strava.*
### Phase 3 — Zwift Click v2
TASK-0 (protocol discovery), then FR-3.13.18, FR-9.4, FR-4.1 (virtual gearing, which needs
TASK-3's resistance-curve calibration), RISK-9 resolution.
*Milestone: shift gears and trim gradient from the bars.*
### Phase 4 — Polish and stretch
FR-1.5 (auto-connect), FR-3.7 (battery), FR-3.14 (mark as unlocked), FR-3.17 (rebinding),
FR-4.6 (ERG), FR-9.11/9.12, Android.
---
## 10. Risks
| ID | Risk | Impact | Mitigation |
|----|------|--------|------------|
| **RISK-1** | `btleplug`'s Android backend is the least mature part of the stack | Android target lost | TASK-4 spikes it in Phase 0 |
| **RISK-2** | Virtual shifting feel may be poor if the D100's resistance curve is coarse or laggy | Core feature degraded | TASK-3 characterises it early; fall back to fewer, wider-spaced gears |
| **RISK-3** | Rust FIT *encoders* are thin — most crates read rather than write | FR-8.2 slips | Port the MIT writer from §3.2; TCX fallback |
| **RISK-4** | **No fallback input path (§3.7).** A firmware change or a protocol error leaves no second way in | Controller input lost entirely until the client is fixed | Accepted deliberately. Keep protocol handling isolated in `ble`; pin known-good behaviour in tests; keyboard/on-screen controls (FR-3.19) are the only stopgap |
| **RISK-5** | The Click v2 crypto is more involved than the D100 work — ECDH, HKDF, AES-CCM, session counters — and a subtle error yields silence rather than a clear failure | Phase 1 slips | Three reference implementations to check against (§3.43.6); `probe` logs raw and decrypted frames side by side (NFR-8) |
| **RISK-6** | The unencrypted path (A-3) may not work on a v2, forcing full crypto immediately | Less schedule slack | TASK-0 tests it first; the crypto path is specified either way |
| **RISK-7** | The unlock expires ~24 h, so a forgotten re-unlock blocks a ride | Frustration at session start | Detect and warn *before* the ride (FR-3.12), with a guided walkthrough (FR-3.10) |
| **RISK-8** | D100 lacks `0x11` sim mode | Reduced fidelity | Low impact — the app owns the physics |
| **RISK-9** | **The left pod is where third-party support is weakest.** QZ has an open `wontfix` issue where the left Click's `` never registers, and BikeControl's keep-alive covers only the right pod. All gradient and route control is mapped to the left pod | Gradient and route control lost; shifting-up survives | **TASK-0 proves both pods before the mapping is committed.** If the left pod is unreliable, remap onto the right pod and move gradient to a modifier gesture |
### 10.1 On ANT+
The D100 likely supports ANT+ FE-C, which permits multiple simultaneous connections and
would sidestep BLE contention. Excluded because it needs a USB stick on desktop and is
effectively dead on Android — the opposite of G-4. Revisit only if BLE contention proves
intolerable.
---
## 11. Open Questions
| ID | Question |
|----|----------|
| **OQ-2** | Gradient step size — is ±0.5% per D-pad press right? And what repeat rate on hold? |
| **OQ-3** | How many virtual gears, and what ratio spread? (Default: 24, roughly a 2×12 road setup.) |
| **OQ-4** | Should route mode auto-advance to the next route on completion, or stop and wait? |
| **OQ-5** | Is heart rate wanted? A BLE HRM strap is a small increment now and awkward to retrofit into the FIT writer later. |
| **OQ-6** | Which frontend framework? Svelte recommended. |
| **OQ-9** | *(§3.7)* Port QZ's GPL-3.0 crypto and licence this project GPL-3.0, or reimplement from the documented algorithm and stay unencumbered? |
| **OQ-10** | If TASK-0 shows the left pod is unreliable (RISK-9), do you want gradient remapped onto the right pod, or would you rather chase the left-pod bug? |
**Resolved:** OQ-1 (paddles shift, D-pad adjusts gradient) · OQ-7 (stay on Tauri, port the
MIT logic) · OQ-8 (no bridge, no fallback input path) · Click version (v2).
---
## 12. Glossary
| Term | Meaning |
|------|---------|
| **FTMS** | Fitness Machine Service — standard BLE profile for trainer telemetry and control |
| **Pod** | One half of a Click v2 — left (D-pad) or right (face buttons); each is an independent BLE peripheral |
| **Unlock** | Refreshing the Click v2's encryption context via the free Zwift app; lasts ~24 h |
| **ECDH / HKDF / AES-CCM** | The key agreement, key derivation and cipher used by the Zwift session (§2.3.1) |
| **ERG mode** | Trainer holds fixed target power regardless of cadence |
| **Sim mode** | Trainer applies resistance simulating a gradient; power varies with rider effort |
| **Virtual shifting** | Synthesising gear changes by varying trainer resistance, on a single-cog drivetrain |
| **Crr** | Coefficient of rolling resistance |
| **CdA** | Drag coefficient × frontal area |
| **Normalised power** | Weighted average power reflecting physiological cost of variable efforts |
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "bikecontrol-ble"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
bikecontrol-core = { workspace = true }
btleplug = { workspace = true }
tokio = { workspace = true }
futures = { workspace = true }
uuid = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
+1
View File
@@ -0,0 +1 @@
//! FTMS client and BLE transport. See REQUIREMENTS.md §5.15.2.
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "bikecontrol-core"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde = { workspace = true }
serde_yaml_ng = { workspace = true }
thiserror = { workspace = true }
roxmltree = { workspace = true }
[dev-dependencies]
approx = "0.5"
+80
View File
@@ -0,0 +1,80 @@
//! GPX import: turn a recorded ride into a gradient profile (§5.5).
//!
//! The hard part is not parsing — it is that **raw GPS elevation is far too
//! noisy to differentiate directly** (FR-5.2). Differentiating unsmoothed
//! elevation produces wild gradient spikes that would make the trainer lurch.
//! Elevation must be smoothed before gradients are derived, and the result
//! clamped (FR-5.3).
use crate::profile::{Profile, TerrainPoint};
/// A single trackpoint read from a GPX file.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TrackPoint {
pub lat_deg: f64,
pub lon_deg: f64,
pub elevation_m: f32,
}
/// Tuning for elevation smoothing and gradient derivation.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SmoothingConfig {
/// Resample the track to this spacing before differentiating, in metres.
/// Larger values give smoother, less twitchy gradients.
pub resample_m: f64,
/// Width of the smoothing window, in metres.
pub window_m: f64,
pub min_gradient_pct: f32,
pub max_gradient_pct: f32,
}
impl Default for SmoothingConfig {
fn default() -> Self {
Self {
resample_m: 10.0,
window_m: 100.0,
min_gradient_pct: -10.0,
max_gradient_pct: 15.0,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum GpxError {
#[error("malformed GPX: {0}")]
Malformed(String),
#[error("GPX contains no track points with elevation")]
NoElevation,
#[error("GPX track is too short to derive a gradient profile")]
TooShort,
}
/// Parse the track points out of a GPX document.
///
/// Must tolerate real-world GPX: `<trk>/<trkseg>/<trkpt>` and `<rte>/<rtept>`,
/// missing `<ele>` on some points, multiple segments, and namespaced documents.
pub fn parse(xml: &str) -> Result<Vec<TrackPoint>, GpxError> {
let _ = xml;
todo!("implemented in crates/core/src/gpx.rs — see AGENT task A")
}
/// Great-circle distance between two points, in metres.
pub fn haversine_m(a: TrackPoint, b: TrackPoint) -> f64 {
let _ = (a, b);
todo!("implemented in crates/core/src/gpx.rs — see AGENT task A")
}
/// Turn track points into a smoothed, clamped gradient profile.
pub fn to_terrain(
points: &[TrackPoint],
cfg: &SmoothingConfig,
) -> Result<Vec<TerrainPoint>, GpxError> {
let _ = (points, cfg);
todo!("implemented in crates/core/src/gpx.rs — see AGENT task A")
}
/// Convenience: GPX document to a ready-to-ride single-block profile.
pub fn import(xml: &str, name: &str, cfg: &SmoothingConfig) -> Result<Profile, GpxError> {
let _ = (xml, name, cfg);
todo!("implemented in crates/core/src/gpx.rs — see AGENT task A")
}
+17
View File
@@ -0,0 +1,17 @@
//! Pure ride logic: physics, profiles, GPX import and session state.
//!
//! This crate has no I/O and no platform dependencies (NFR-5). Everything here
//! is unit-testable with synthetic telemetry, and it must stay that way — BLE
//! lives in `bikecontrol-ble`, file writing in `bikecontrol-fit`.
pub mod gpx;
pub mod physics;
pub mod profile;
pub mod session;
pub mod types;
pub use profile::{Block, Channel, Extent, Profile, Segment, Waveform};
pub use session::{RideSession, SessionEvent};
pub use types::{
ConnectionState, ControlMode, ControlTarget, RideSnapshot, RiderConfig, SafetyLimits, Telemetry,
};
+63
View File
@@ -0,0 +1,63 @@
//! Virtual speed from measured power (§5.7 of REQUIREMENTS.md).
//!
//! The app owns the physics rather than trusting the trainer's reported speed
//! (FR-7.1). This makes ride behaviour reproducible in tests, independent of
//! the trainer's internal mass assumptions, and is a prerequisite for virtual
//! gearing later.
//!
//! Per tick:
//! ```text
//! F_propulsive = (P × drivetrain_efficiency) / max(v, v_min)
//! F_gravity = m × g × sin(atan(gradient))
//! F_rolling = m × g × Crr × cos(atan(gradient))
//! F_aero = ½ × ρ × CdA × v²
//! a = (F_propulsive F_gravity F_rolling F_aero) / m
//! v += a × Δt (clamped at ≥ 0)
//! ```
use crate::types::RiderConfig;
pub const GRAVITY: f32 = 9.80665;
/// Speed floor used to keep `P / v` finite at a standstill. Also the speed
/// below which the rider is considered stopped.
pub const MIN_SPEED_MPS: f32 = 0.5;
/// Evolving physical state of the virtual rider.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct PhysicsState {
/// Virtual speed, metres per second.
pub speed_mps: f32,
/// Virtual distance travelled, metres.
pub distance_m: f64,
/// Cumulative elevation gained, metres.
pub elevation_gain_m: f32,
}
impl PhysicsState {
/// Advance the simulation by `dt` seconds under `power_w` at `gradient_pct`.
///
/// Must model inertia (FR-7.3) — speed accelerates toward equilibrium
/// rather than snapping to it — and must never produce negative speed,
/// NaN, or unbounded values for any finite input.
pub fn step(&mut self, power_w: f32, gradient_pct: f32, cfg: &RiderConfig, dt: f32) {
let _ = (power_w, gradient_pct, cfg, dt);
todo!("implemented in crates/core/src/physics.rs — see AGENT task A")
}
pub fn speed_kph(&self) -> f32 {
self.speed_mps * 3.6
}
pub fn is_moving(&self) -> bool {
self.speed_mps > MIN_SPEED_MPS
}
}
/// Steady-state speed for a given power and gradient — the speed at which
/// propulsive and resistive forces balance. Useful for tests and for sanity
/// checks on the resistance curve later.
pub fn equilibrium_speed_mps(power_w: f32, gradient_pct: f32, cfg: &RiderConfig) -> f32 {
let _ = (power_w, gradient_pct, cfg);
todo!("implemented in crates/core/src/physics.rs — see AGENT task A")
}
+235
View File
@@ -0,0 +1,235 @@
//! Ride profiles: terrain segments, synthetic waveforms, and the file format
//! that carries both (§5.5, §5.6 of REQUIREMENTS.md).
//!
//! A profile is an ordered list of blocks. Each block drives one channel
//! (gradient, resistance or power) for either a duration or a distance. The
//! engine asks the profile for a target given elapsed time and distance
//! travelled, and the profile decides which block is active and what it wants.
use serde::{Deserialize, Serialize};
use crate::types::ControlTarget;
/// Which trainer parameter a block drives (FR-6.2).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Channel {
Gradient,
Resistance,
Power,
}
/// Waveform shapes (FR-6.1). All are evaluated as a function of phase in
/// `[0, 1)` and produce a value in `[-1, 1]`, which the block then scales by
/// amplitude and offsets by midpoint.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Waveform {
Sine,
Square,
Triangle,
Sawtooth,
}
impl Waveform {
/// Evaluate at `phase` in `[0, 1)`, returning `[-1, 1]`.
pub fn eval(self, phase: f32) -> f32 {
let p = phase.rem_euclid(1.0);
match self {
Waveform::Sine => (p * std::f32::consts::TAU).sin(),
Waveform::Square => {
if p < 0.5 {
1.0
} else {
-1.0
}
}
Waveform::Triangle => {
// Rises 0→1 over the first quarter, falls 1→-1, returns to 0.
4.0 * (p - (p + 0.25).floor()).abs() - 1.0
}
Waveform::Sawtooth => 2.0 * p - 1.0,
}
}
}
/// How a block measures its own extent — by time or by distance (FR-6.4).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Extent {
Seconds(f64),
Metres(f64),
}
/// A single terrain segment: hold a gradient for a distance (FR-5.4).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Segment {
pub distance_m: f64,
pub gradient_pct: f32,
}
/// One block of a profile.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum Block {
/// Hold a fixed value.
Constant {
channel: Channel,
value: f32,
extent: Extent,
},
/// Linear sweep between two values.
Ramp {
channel: Channel,
from: f32,
to: f32,
extent: Extent,
},
/// Oscillate around a midpoint.
Wave {
channel: Channel,
shape: Waveform,
midpoint: f32,
amplitude: f32,
/// Length of one full cycle.
period: Extent,
/// Number of cycles. Total extent = period × repeats.
repeats: f32,
/// Phase offset in `[0, 1)`.
#[serde(default)]
phase: f32,
},
/// A terrain profile: gradient as a function of distance, interpolated
/// between points (FR-5.5).
Segments { segments: Vec<Segment> },
/// A gradient/distance profile derived from a GPX import. Points are
/// cumulative distance in metres paired with gradient in percent.
Terrain { points: Vec<TerrainPoint> },
}
/// One point of an elevation-derived gradient profile.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct TerrainPoint {
pub distance_m: f64,
pub gradient_pct: f32,
/// Elevation in metres, retained for display of the profile chart.
pub elevation_m: f32,
}
/// A complete ride profile.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Profile {
pub name: String,
#[serde(default)]
pub description: Option<String>,
pub blocks: Vec<Block>,
/// Restart from the beginning on completion (FR-5.6).
#[serde(default)]
pub looping: bool,
}
/// Where the rider currently is within a profile.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Position {
pub elapsed_s: f64,
pub distance_m: f64,
}
#[derive(Debug, thiserror::Error)]
pub enum ProfileError {
#[error("failed to parse profile: {0}")]
Parse(String),
#[error("profile has no blocks")]
Empty,
#[error("block {index} is invalid: {reason}")]
InvalidBlock { index: usize, reason: String },
}
impl Profile {
/// Parse a profile from YAML.
pub fn from_yaml(src: &str) -> Result<Self, ProfileError> {
let profile: Profile =
serde_yaml_ng::from_str(src).map_err(|e| ProfileError::Parse(e.to_string()))?;
profile.validate()?;
Ok(profile)
}
/// Reject profiles that cannot be evaluated, so failures surface at load
/// time rather than mid-ride.
pub fn validate(&self) -> Result<(), ProfileError> {
if self.blocks.is_empty() {
return Err(ProfileError::Empty);
}
for (index, block) in self.blocks.iter().enumerate() {
block
.validate()
.map_err(|reason| ProfileError::InvalidBlock { index, reason })?;
}
Ok(())
}
/// The target this profile wants at `position`, or `None` if the profile
/// has finished and is not looping.
///
/// Implementations must clamp nothing here — safety clamping happens once,
/// at transmission (SAF-3).
pub fn sample(&self, position: Position) -> Option<ControlTarget> {
let _ = position;
todo!("implemented in crates/core/src/profile.rs — see AGENT task A")
}
/// Total extent of the profile, if finite. Used for progress display
/// (FR-9.7) and to know when a non-looping profile has ended.
pub fn total_extent(&self) -> ProfileExtent {
todo!("implemented in crates/core/src/profile.rs — see AGENT task A")
}
/// Sample the whole profile ahead of time for the preview chart (FR-6.7).
/// Returns `(x, value)` pairs where `x` is seconds or metres depending on
/// the profile's dominant extent kind.
pub fn preview(&self, samples: usize) -> Vec<(f64, f32)> {
let _ = samples;
todo!("implemented in crates/core/src/profile.rs — see AGENT task A")
}
}
/// Total length of a profile, which may be measured in time, distance, both or
/// neither (a profile of only unbounded blocks).
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct ProfileExtent {
pub seconds: Option<f64>,
pub metres: Option<f64>,
}
impl Block {
pub fn channel(&self) -> Channel {
match self {
Block::Constant { channel, .. }
| Block::Ramp { channel, .. }
| Block::Wave { channel, .. } => *channel,
Block::Segments { .. } | Block::Terrain { .. } => Channel::Gradient,
}
}
fn validate(&self) -> Result<(), String> {
match self {
Block::Wave { repeats, period, .. } => {
if *repeats <= 0.0 {
return Err("repeats must be positive".into());
}
match period {
Extent::Seconds(s) if *s <= 0.0 => Err("period must be positive".into()),
Extent::Metres(m) if *m <= 0.0 => Err("period must be positive".into()),
_ => Ok(()),
}
}
Block::Segments { segments } if segments.is_empty() => {
Err("segments block is empty".into())
}
Block::Terrain { points } if points.len() < 2 => {
Err("terrain block needs at least two points".into())
}
_ => Ok(()),
}
}
}
+117
View File
@@ -0,0 +1,117 @@
//! The ride state machine: ties telemetry, physics and the active profile
//! together and decides what to command the trainer.
//!
//! 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::physics::PhysicsState;
use crate::profile::{Position, Profile};
use crate::types::{
ControlMode, ControlTarget, RideSnapshot, RiderConfig, SafetyLimits, Telemetry,
};
/// Something the session wants the outside world to do or know about.
#[derive(Debug, Clone, PartialEq)]
pub enum SessionEvent {
/// Send this target to the trainer. Already clamped (SAF-3).
Command(ControlTarget),
/// A new snapshot is available for the UI.
Snapshot(RideSnapshot),
/// A non-looping profile reached its end.
ProfileFinished,
/// The rider crossed into a new lap.
Lap { index: u32 },
}
/// Ride lifecycle.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RideStatus {
Idle,
Running,
Paused,
Finished,
}
pub struct RideSession {
pub config: RiderConfig,
pub limits: SafetyLimits,
pub mode: ControlMode,
pub status: RideStatus,
physics: PhysicsState,
profile: Option<Profile>,
/// Manual gradient trim applied on top of the profile's gradient.
gradient_offset_pct: f32,
elapsed_ms: u64,
last_target: Option<ControlTarget>,
}
impl RideSession {
pub fn new(config: RiderConfig, limits: SafetyLimits) -> Self {
Self {
config,
limits,
mode: ControlMode::ManualGrade,
status: RideStatus::Idle,
physics: PhysicsState::default(),
profile: None,
gradient_offset_pct: 0.0,
elapsed_ms: 0,
last_target: None,
}
}
pub fn load_profile(&mut self, profile: Profile) {
self.profile = Some(profile);
self.mode = ControlMode::Profile;
}
pub fn profile(&self) -> Option<&Profile> {
self.profile.as_ref()
}
pub fn position(&self) -> Position {
Position {
elapsed_s: self.elapsed_ms as f64 / 1000.0,
distance_m: self.physics.distance_m,
}
}
pub fn start(&mut self) {
self.status = RideStatus::Running;
}
pub fn pause(&mut self) {
self.status = RideStatus::Paused;
}
/// Adjust the manual gradient trim by `delta` percent (FR-4.2).
pub fn nudge_gradient(&mut self, delta_pct: f32) {
self.gradient_offset_pct += delta_pct;
}
pub fn reset_gradient_offset(&mut self) {
self.gradient_offset_pct = 0.0;
}
/// Advance the ride by one tick.
///
/// Feeds telemetry into the physics model, advances the profile, and
/// returns whatever the outside world needs to act on. Must be safe to call
/// when paused (no distance accrues) and when telemetry is missing power
/// (treat as zero rather than panicking).
pub fn tick(&mut self, telemetry: Telemetry, dt_s: f32) -> Vec<SessionEvent> {
let _ = (telemetry, dt_s);
todo!("implemented in crates/core/src/session.rs — see AGENT task A")
}
/// Build the snapshot the UI renders.
pub fn snapshot(&self, telemetry: Telemetry) -> RideSnapshot {
let _ = telemetry;
todo!("implemented in crates/core/src/session.rs — see AGENT task A")
}
/// The target that should be in force right now, before clamping.
fn desired_target(&self) -> Option<ControlTarget> {
todo!("implemented in crates/core/src/session.rs — see AGENT task A")
}
}
+170
View File
@@ -0,0 +1,170 @@
//! Shared types. This module is the contract between the BLE layer, the ride
//! engine, the recorder and the UI. Change it deliberately.
use serde::{Deserialize, Serialize};
/// One telemetry sample decoded from the trainer's Indoor Bike Data
/// characteristic. Every field is optional because FTMS packets are
/// variable-length — presence is driven by the leading flags bitfield, and a
/// given trainer may never send some of them.
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
pub struct Telemetry {
/// Milliseconds since the ride started.
pub elapsed_ms: u64,
pub power_w: Option<i16>,
pub cadence_rpm: Option<f32>,
/// Trainer-reported speed. Diagnostic only — the ride engine computes its
/// own virtual speed from power (FR-7.1, FR-7.5).
pub speed_kph: Option<f32>,
pub resistance_level: Option<i16>,
pub heart_rate_bpm: Option<u8>,
pub total_distance_m: Option<u32>,
pub total_energy_kcal: Option<u16>,
}
/// A command to the trainer. Which variant is used depends on the active
/// [`ControlMode`] and on what the trainer actually supports (FR-2.6).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum ControlTarget {
/// Simulated gradient, in percent. Positive is uphill.
Gradient { percent: f32 },
/// Raw trainer resistance level, in the trainer's own units.
Resistance { level: i16 },
/// Target power in watts (ERG-style).
Power { watts: u16 },
}
/// Hard limits applied at the point of transmission, regardless of where the
/// target came from (SAF-3, SAF-6). A profile with absurd parameters must not
/// be able to command an unsafe target.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct SafetyLimits {
pub min_gradient_pct: f32,
pub max_gradient_pct: f32,
pub min_resistance: i16,
pub max_resistance: i16,
pub min_power_w: u16,
pub max_power_w: u16,
}
impl Default for SafetyLimits {
fn default() -> Self {
// Gradient range per FR-5.3; power range per the D100 reference
// implementation (§3.2 of REQUIREMENTS.md).
Self {
min_gradient_pct: -10.0,
max_gradient_pct: 15.0,
min_resistance: 0,
max_resistance: 100,
min_power_w: 50,
max_power_w: 600,
}
}
}
impl SafetyLimits {
/// Clamp a target into the safe range. Every path to the trainer must go
/// through this.
pub fn clamp(&self, target: ControlTarget) -> ControlTarget {
match target {
ControlTarget::Gradient { percent } => ControlTarget::Gradient {
percent: percent.clamp(self.min_gradient_pct, self.max_gradient_pct),
},
ControlTarget::Resistance { level } => ControlTarget::Resistance {
level: level.clamp(self.min_resistance, self.max_resistance),
},
ControlTarget::Power { watts } => ControlTarget::Power {
watts: watts.clamp(self.min_power_w, self.max_power_w),
},
}
}
}
/// Where the base target comes from (§5.4). Note that in the full design
/// gearing and gradient are simultaneously active; mode selects the *source* of
/// the base gradient, not whether shifting works.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ControlMode {
/// Rider sets gradient directly; no profile running.
ManualGrade,
/// Rider sets raw resistance; physics ignored.
Resistance,
/// Gradient driven by a loaded profile at the current distance/time.
Profile,
/// Fixed target power.
Erg,
}
/// Rider and bike parameters feeding the physics model (FR-7.4).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct RiderConfig {
pub rider_kg: f32,
pub bike_kg: f32,
/// Coefficient of rolling resistance.
pub crr: f32,
/// Drag coefficient × frontal area, m².
pub cda: f32,
/// Fraction of measured power reaching the wheel.
pub drivetrain_efficiency: f32,
/// Air density, kg/m³.
pub air_density: f32,
pub wheel_circumference_m: f32,
}
impl Default for RiderConfig {
fn default() -> Self {
Self {
rider_kg: 75.0,
bike_kg: 8.0,
crr: 0.004,
cda: 0.32,
drivetrain_efficiency: 0.97,
air_density: 1.225,
wheel_circumference_m: 2.105,
}
}
}
impl RiderConfig {
pub fn total_mass_kg(&self) -> f32 {
self.rider_kg + self.bike_kg
}
}
/// A snapshot of the ride, pushed to the UI each tick. This is what the
/// frontend renders; it should contain everything the ride screen needs and
/// nothing it does not.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct RideSnapshot {
pub elapsed_ms: u64,
pub telemetry: Telemetry,
/// Virtual speed computed by the physics engine, km/h.
pub virtual_speed_kph: f32,
/// Virtual distance travelled, metres.
pub virtual_distance_m: f64,
/// Gradient currently commanded, percent.
pub gradient_pct: f32,
/// Cumulative elevation gained, metres.
pub elevation_gain_m: f32,
pub mode: ControlMode,
/// The target most recently sent to the trainer, post-clamp.
pub target: Option<ControlTarget>,
/// Fractional progress through the loaded profile, 0.01.0, if one is
/// loaded and has finite length.
pub profile_progress: Option<f32>,
}
/// Connection state for a single BLE peripheral (FR-1.7).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ConnectionState {
Idle,
Scanning,
Connecting,
/// Connected at the BLE level but control not yet acquired. For a trainer,
/// connected ≠ controllable (FR-9.3).
Connected,
/// FTMS control point acquired; commands will be accepted.
Controlling,
Reconnecting,
Lost { reason: String },
}
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "bikecontrol-fit"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
bikecontrol-core = { workspace = true }
thiserror = { workspace = true }
chrono = { workspace = true }
+1
View File
@@ -0,0 +1 @@
//! FIT activity file encoder. See REQUIREMENTS.md §5.8.
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "bikecontrol-probe"
version.workspace = true
edition.workspace = true
license.workspace = true
[[bin]]
name = "probe"
path = "src/main.rs"
[dependencies]
bikecontrol-core = { workspace = true }
bikecontrol-ble = { workspace = true }
btleplug = { workspace = true }
tokio = { workspace = true, features = ["full"] }
futures = { workspace = true }
uuid = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
+1
View File
@@ -0,0 +1 @@
fn main() { println!("probe: not yet implemented"); }
+40
View File
@@ -0,0 +1,40 @@
[package]
name = "bikecontrol-app"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "BikeControl desktop shell: Tauri commands and the event bridge to the UI"
[lib]
# Tauri v2 convention: the app lives in a lib so the same code can be reused by
# the Android/iOS entry points later (G-4).
name = "bikecontrol_app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
bikecontrol-core = { workspace = true }
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
serde = { workspace = true }
serde_json = { workspace = true }
serde_yaml_ng = { workspace = true }
tokio = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
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.
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 = []
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build();
}
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Permissions for the BikeControl main window.",
"windows": ["main"],
"permissions": [
"core:default",
"core:event:default",
"core:window:default",
"core:webview:default",
"core:app:default",
"dialog:default"
]
}
+48
View File
@@ -0,0 +1,48 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "BikeControl",
"version": "0.1.0",
"identifier": "paris.tourolle.bikecontrol",
"build": {
"frontendDist": "../ui/dist",
"devUrl": "http://localhost:1420",
"beforeDevCommand": {
"cwd": "../ui",
"script": "npm run dev"
},
"beforeBuildCommand": {
"cwd": "../ui",
"script": "npm run build"
}
},
"app": {
"windows": [
{
"title": "BikeControl",
"width": 1440,
"height": 900,
"minWidth": 960,
"minHeight": 640,
"resizable": true,
"fullscreen": false,
"backgroundColor": "#07090d"
}
],
"security": {
"csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src ipc: http://ipc.localhost"
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.png"
],
"category": "Utility",
"shortDescription": "Indoor cycling trainer control",
"longDescription": "Control a smart trainer over BLE, ride gradient profiles and synthetic waveforms, and record the result."
}
}