Virtual gearing, trainer-speed blend, and cadence decode
Gears are expressed as an offset to the commanded gradient, leaving the physics on the route's true gradient so shifting changes effort, not speed. Neutral gear commands exactly the route gradient, so an un-shifted ride is unchanged. Cadence is not in FTMS on this trainer but is on its Zwift channel, decoded against captured frames. The undeclared FTMS trailing bytes were ruled out: wheel RPM restated at a fixed 73.8x speed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,14 +13,19 @@ See [REQUIREMENTS.md](REQUIREMENTS.md) for the full specification.
|
||||
| Component | State |
|
||||
|-----------|-------|
|
||||
| `crates/core` — physics, profiles, GPX import, ride engine | Implemented, 103 tests |
|
||||
| `crates/ble` — FTMS client for the D100 | Implemented, 96 tests. **Not yet wired into the app** |
|
||||
| `crates/ble` — FTMS client for the D100, Zwift Click protocol | Implemented, 108 tests. **FTMS and the Click are both wired into the app** |
|
||||
| `crates/fit` — FIT activity encoder | Implemented, 106 tests. **Not yet wired into the app** |
|
||||
| `crates/probe` — hardware discovery CLI | **Works against the real trainer** |
|
||||
| `src-tauri` + `ui` — desktop app | Runs on a **mock rider**, not the real trainer |
|
||||
| `src-tauri` + `ui` — desktop app | **Rides the real trainer**: scan → connect → control → telemetry |
|
||||
|
||||
The GUI and the trainer are both working, but **not yet connected to each other** — the
|
||||
app ticks a synthetic rider while `probe` talks to the real hardware. Joining them is the
|
||||
next step.
|
||||
The app drives the hardware end to end: it scans over BlueZ, connects, acquires FTMS
|
||||
control, feeds Indoor Bike Data into the ride engine, and writes control targets back at
|
||||
4 Hz. Recording to FIT is the remaining gap.
|
||||
|
||||
The synthetic rider is still available for GUI work with no hardware on the desk —
|
||||
`BIKECONTROL_MOCK=1`, or `BIKECONTROL_DEMO=1` which also loads the sample route and starts
|
||||
riding. Both need the `mock-ride` feature, which is on by default;
|
||||
`--no-default-features` produces a binary that can only ever show real trainer data.
|
||||
|
||||
---
|
||||
|
||||
@@ -79,6 +84,27 @@ cargo tauri dev
|
||||
|
||||
Every shortcut is mirrored by an on-screen control.
|
||||
|
||||
### Zwift Click
|
||||
|
||||
Connect from the device screen — **press a button on the pod first**, since a Click only
|
||||
advertises while awake. Each button routes to the same intent as the equivalent key, so the
|
||||
two can never drift apart:
|
||||
|
||||
| Button | Action |
|
||||
|--------|--------|
|
||||
| `+` / `−` | Shift a gear: ±10 W in ERG, one level in resistance mode, else gradient ±0.5% |
|
||||
| D-pad `↑` / `↓` | Gradient +0.5% / −0.5% |
|
||||
| D-pad `←` / `→` | Device screen / ride screen |
|
||||
| `A` | Pause / resume |
|
||||
| `B` | Insert lap marker |
|
||||
| `Y` | Cycle control mode |
|
||||
| `Z` | Profiles and routes |
|
||||
|
||||
Shifting is **emulated app-side**. The D100 exposes no virtual-shifting command surface —
|
||||
its Zwift service only streams telemetry (REQUIREMENTS.md §2.3.2) — so a "gear" is a step
|
||||
in whatever target the active control mode drives. The keyboard remains the fallback if a
|
||||
pod's battery dies mid-ride.
|
||||
|
||||
---
|
||||
|
||||
## Talking to the trainer
|
||||
@@ -94,6 +120,7 @@ cargo build -p bikecontrol-probe
|
||||
./target/debug/probe inspect --name VANRYSEL # services, characteristics, capabilities
|
||||
./target/debug/probe monitor --name VANRYSEL # live telemetry: raw hex + decoded
|
||||
./target/debug/probe set --name VANRYSEL sim=4.0 # apply a target, then auto-reset
|
||||
./target/debug/probe zwift <ADDR> # Zwift Click: handshake, then log frames
|
||||
```
|
||||
|
||||
`set` accepts `gradient=<pct>`, `sim=<pct>`, `resistance=<level>` or `power=<watts>`, and
|
||||
@@ -123,6 +150,45 @@ Note `SetTargetInclination (0x03)` is **not** supported, and its range character
|
||||
reports only 0–6% with no negatives — so simulation mode is the only usable path for
|
||||
gradient.
|
||||
|
||||
### What the Zwift Click v2 reports
|
||||
|
||||
Confirmed against the hardware — the pods talk to us **unencrypted**:
|
||||
|
||||
```
|
||||
Zwift Click (two pods, one BLE peripheral each)
|
||||
advertises 0xFC82, manufacturer 0x094a: 0a… and 0b… <-- type byte per pod
|
||||
service 0xFC82 wraps the familiar Zwift characteristics:
|
||||
00000002-19ca-… notify device events
|
||||
00000003-19ca-… write-without-response commands in
|
||||
00000004-19ca-… read, indicate responses out
|
||||
00000100/0101/0102-19ca-… undocumented, silent so far
|
||||
|
||||
-> 526964654f6e0009 ("RideOn" + 00 09)
|
||||
<- 526964654f6e0203 ("RideOn" + 02 03) no key exchange, no encryption
|
||||
<- 191064 battery 100%
|
||||
<- 2308f7ffffff0f buttons: mask 0xfffffff7, bit 3 held
|
||||
```
|
||||
|
||||
Two things differ from the public write-ups: the v2 puts everything under `0xFC82` rather
|
||||
than the trainer's `00000001-19ca-…` service, and it reports buttons as a **32-bit
|
||||
active-low bitmask** (`0x23`) rather than the documented two-varint `0x37` message. Idle is
|
||||
`0xffffffff`; a clear bit means pressed.
|
||||
|
||||
The bit map is confirmed against the hardware:
|
||||
|
||||
| Bit | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 |
|
||||
|-----|---|---|---|---|---|---|---|---|---|----|
|
||||
| Button | `left` | `up` | `right` | `down` | `A` | `B` | `Y` | `Z` | `−` | `+` |
|
||||
|
||||
D-pad on 0–3, face buttons on 4–7, paddles at 8 and 12; bits 9–11 are unclaimed. Use
|
||||
`probe zwift <ADDR> --buttons` to see named presses one line at a time:
|
||||
|
||||
```
|
||||
[ 3.51s] PRESS #1 A (bit 4) mask 0xffffffef
|
||||
[ 5.44s] RELEASE --- mask 0xffffffff
|
||||
[ 6.16s] PRESS #2 Z (bit 7) mask 0xffffff7f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Profiles
|
||||
|
||||
Reference in New Issue
Block a user