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:
+20
@@ -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
File diff suppressed because it is too large
Load Diff
+32
@@ -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
@@ -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.4–3.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 100–600 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 10–30 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.4–3.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 0–31 = AES key, bytes 32–35 = 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 10–30 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 (1–4 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.1–9.3, 9.5–9.8, 9.10–9.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.1–3.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.4–3.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 |
|
||||||
@@ -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 }
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
//! FTMS client and BLE transport. See REQUIREMENTS.md §5.1–5.2.
|
||||||
@@ -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"
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.0–1.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 },
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
//! FIT activity file encoder. See REQUIREMENTS.md §5.8.
|
||||||
@@ -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 }
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
fn main() { println!("probe: not yet implemented"); }
|
||||||
@@ -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 = []
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build();
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user