Commit Graph
4 Commits
Author SHA1 Message Date
dtourolleandClaude Opus 5 aa99b83c40 Make the Android BLE backend fail loudly and recoverably
Three defects on the path between MainActivity and the scan loop, all of
which presented as "no trainer found".

initBtleplug ran after super.onCreate, which is a race rather than a
clean ordering bug: the super chain dispatches Rust.create(), and tao's
ndk_glue spawns a thread to run `run()` on. That thread builds the
AppState and starts the scan loop concurrently. Reaching btleplug first
hits droidplug's global_adapter(), which is an `expect` — the scan task
panics and scanning is dead for the process, silently and only on some
phones. Initialising before super.onCreate means the race cannot be lost.

The same panic was reachable without any race, because init failure was
logged and shrugged off while every later call still went through to
`expect`. Failing soft is right; it just needed READY, so the call sites
can produce an ordinary "no adapter" instead of taking the task down
(NFR-4). MainActivity retries the init on resume, which is idempotent, so
a rider who launched with Bluetooth off recovers by going to Settings.

Neither of those covers a radio the rider switches off, which btleplug
does not model at all: getDefaultAdapter() returns a disabled adapter
whose scans just find nothing. MainActivity now watches
ACTION_STATE_CHANGED — the quick-settings shade never fires onResume —
and pushes the state to Rust, with requestBluetoothEnable coming back the
other way so the connection screen can offer the system dialog rather
than describing an empty room. Tri-state on purpose: unknown is not off,
or a rider with a working radio gets told to switch it on at launch.

Also: the adapter hint told Android riders to check BlueZ.

Verified on debug and release APKs for aarch64. Release matters
separately here — every one of these classes is reached only by name over
JNI, so R8 would strip or rename the lot and the failure would appear
only in a shipped build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 20:06:03 +02:00
dtourolleandClaude Opus 5 7b511db3dc 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>
2026-08-05 18:21:08 +02:00
dtourolleandClaude Opus 5 57eb5e809b 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>
2026-08-05 15:33:28 +02:00
dtourolleandClaude Opus 5 7c17ca6158 Core ride logic, FTMS client, FIT encoder and probe CLI
Adds backing state for Resistance and Erg control modes, which had no
value to hold and so could never satisfy FR-4.3/FR-4.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 13:34:27 +02:00