Ride the drivetrain, command the load in watts

Speed now comes from the drivetrain and the load from the road, which is
the way round a bike actually works.

Speed is cadence x development, filtered lightly. Power, not cadence,
decides whether the rider is driving it: on a direct-drive trainer the
flywheel keeps the cranks turning after they stop, so cadence alone reads
a healthy 80 rpm for someone doing nothing. Below 15 W the speed runs
down to whatever the gradient sustains on no power - zero uphill, a real
freewheeling speed on a descent. Stopping on a 3.5% climb used to settle
at 22 km/h and stay there, because the model wanted to decelerate and a
blend toward the flywheel speed outvoted it; that blend is gone.

The D100 sends no cadence over FTMS - it is a rebadged Magene T110 with
cadence disabled in firmware (qdomyos-zwift#3282) - so it is inferred
from wheel speed, which one sprocket and no freewheel make exact. Its
Zwift channel does carry cadence, and is now greeted with RideOn and
subscribed on every notifying characteristic, so a measured value is used
where one arrives.

The load is commanded as power, not gradient. The trainer declares
50-600 W in 1 W steps against 0-6% inclination in 0.1% steps refusing
negatives, and whether it acts on 0x11 at all is still unconfirmed. Its
power target is a ceiling rather than a setpoint, which is very nearly
what a road is: exceed it and the surplus becomes speed. Gravity travels
on the same channel as watts, so nothing is lost by leaving 0x11 alone.
LoadChannel keeps the gradient path selectable and tested.

Virtual shifting reaches the trainer for the first time. The physics
load model was written but never called, and a paddle press both shifted
a gear in Rust and nudged the gradient in the webview - the shift
silently, the tilt visibly, so the paddles looked like a gradient trim.

Also: a fixed 12 W drivetrain loss, held as a power because that is how
it presents; crank length, so a gear can be reported as the force it puts
under the foot; gear and pedal force on the ride screen; a drag-race
profile for testing gearing on the flat.

Two readout bugs fixed on the way. The rolling windows were trimmed by
timestamp but fed on a fixed timer, so every second spent on the ride
screen before starting pushed samples at t=0 that could never expire -
speed read a fraction of the truth for the first 45 s. And the headline
speed was a 45 s mean, which took most of a minute to show a gear change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 18:21:08 +02:00
co-authored by Claude Opus 5
parent f2c4cb2120
commit 7b511db3dc
44 changed files with 6636 additions and 950 deletions
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# TASK-2: does a control write actually change what the pedals feel like?
# Every write comes back acknowledged; nobody has confirmed it does anything.
#
# Four connect/hold/disconnect cycles against one adapter, which is where this
# script's own hazard lives — see `settle` and the EXIT trap below. A run that
# leaves the trainer connected at the BlueZ level poisons the *next* run with
# `le-connection-abort-by-local`, and the failure looks like a trainer fault
# rather than the script's.
set -uo pipefail
P=./target/debug/probe
NAME=VANRYSEL
D="--name $NAME"
# Seconds to let BlueZ tear a link down before asking it to build another. A
# connect issued straight after a disconnect is the reliable way to provoke
# `le-connection-abort-by-local`.
SETTLE=4
if [[ ! -x $P ]]; then
echo "Build the probe first: cargo build -p bikecontrol-probe" >&2
exit 1
fi
# The address of the trainer, if BlueZ currently knows one. Used only for
# cleanup — the probe finds its own device by name.
trainer_addr () {
bluetoothctl devices 2>/dev/null | awk -v n="$NAME" '$3 ~ n {print $2; exit}'
}
# Drop any GATT link BlueZ still holds.
#
# This is the whole point of the trap: the probe disconnects cleanly on its own
# exit path, but it never gets there if the run is interrupted — Ctrl-C reaches
# every process in the foreground group, so bash can die while the probe is
# mid-hold and leave the link open with nothing owning it. The next run then
# fails at connect for reasons that have nothing to do with the trainer.
cleanup () {
local addr
addr=$(trainer_addr)
[[ -z $addr ]] && return 0
if bluetoothctl info "$addr" 2>/dev/null | grep -q "Connected: yes"; then
echo
echo " (releasing GATT link to $addr)"
bluetoothctl disconnect "$addr" >/dev/null 2>&1
sleep 2
fi
}
trap cleanup EXIT INT TERM
step () {
local target=$1 title=$2
echo
echo "════════════════════════════════════════════"
echo " $title"
echo "════════════════════════════════════════════"
echo " connecting (~8s), then holding for 20s — PEDAL THROUGHOUT"
# Output is NOT piped. `| tail -3` swallowed the live telemetry this test
# exists to produce, and hid connect errors until the run was over. It also
# put the probe in a pipeline, so its exit status vanished behind tail's and
# `set -e` never fired on a failed step.
if ! $P set $D "$target" --secs 20; then
echo
echo " !! step failed: $target" >&2
cleanup
return 1
fi
# Let the link fully drop before the next step reconnects.
sleep "$SETTLE"
}
# Start from a clean slate: a link left over from a previous run is the most
# common cause of the first step failing.
cleanup
echo "Wake the trainer first: spin the cranks for a few seconds."
echo "Press Enter when it's awake and you're ready to ride."
read -r
step resistance=15 "TEST 1 of 4 — resistance 15 of 100 (should feel EASY)" || exit 1
step resistance=75 "TEST 2 of 4 — resistance 75 of 100 (should feel MUCH HARDER)" || exit 1
step sim=0.0 "TEST 3 of 4 — simulated gradient 0% (flat)" || exit 1
step sim=8.0 "TEST 4 of 4 — simulated gradient +8% (a climb)" || exit 1
echo
echo "Done — trainer reset to zero load."
echo
echo "Report back:"
echo " a) Did 2 feel harder than 1? (resistance mode works)"
echo " b) Did 4 feel harder than 3? (SIM MODE works — this is the one that matters)"
echo " c) Roughly how much harder, and did it change instantly or drift in?"