Three changes, all from the same evening on the hardware.
**Never adopt an existing link.** `setup_session` skipped connecting when
it found the peripheral already connected, which is not a shortcut: it
means someone else left it that way, and after a shutdown that ran out of
budget that someone is our own previous run. The inherited session
answers the handshake and streams battery every five seconds while never
delivering a button, which reads as broken hardware — it was diagnosed as
a dead pod, a wrong bit map, mis-filed pods and a lapsed Zwift unlock
before anyone looked at what the last run failed to close. Any
pre-existing link is now dropped first, in all three actors, so every
connection starts identical.
**A watchdog for the same state, should it arise another way.**
Deliberately narrow: a link that is plainly alive — frames arriving
inside STALE_AFTER — and has never carried a button since it came up is
recycled after 150 s. Not "the rider has not shifted lately", which is
normal and would strand a pod that only advertises while awake. A link
that has delivered even one press is exempt for its lifetime.
**`Y` on the `+` pod shifts down.** Shifting down lived entirely on the
`−` pod's paddle, so one pod was a single point of failure for half the
drivetrain — and with no on-screen gear control on Android, a rider whose
left pod goes quiet is stuck in whatever gear they were in, mid-interval,
with no way out. This is RISK-9's documented mitigation and it should
have been there from the start. Applied in Rust beside the paddles so a
shift behaves the same wherever it comes from; removed from the webview
so it cannot fire twice. Mode cycling keeps the `m` key.
Verified on the tablet: `Y` moved the gear 12 -> 9, and the pods
reconnected cleanly with no stale link to purge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A pod that was connected, reporting battery every five seconds, and
sending no button frames at all cost most of an evening. The cause was
not in the decode: it was that the previous run never let go of the link.
`Actor::teardown` unsubscribed every notifying characteristic before
disconnecting, each under its own two-second timeout. A Click carries
five of them, so the exit path could spend ten seconds on optional work
against a supervisor budget of three. The supervisor gave up first, the
process exited, and `disconnect` was never reached — leaving BlueZ
holding the pod with no application running. The next connect inherited
that half-dead session, and a half-dead session streams battery and no
buttons, which reads exactly like broken hardware.
Measured directly: both apps stopped, `bluetoothctl devices Connected`
still listing the pod. It also explains the connect failures that came
with it — `le-connection-abort-by-local`, then `service discovery timed
out` — and why the fault came and went, since it depended on whether the
last exit happened to time out.
Nothing is lost by dropping the unsubscribes. The link going down clears
the peripheral's CCCDs anyway, so they were only ever politeness toward a
connection about to be destroyed. The heart rate actor had the same
shape with one characteristic instead of five; the trainer's safety
writes stay, because SAF-2 is not optional.
All three now say what a timed-out disconnect costs. The old message
named the symptom and not the consequence, and the consequence lands on
the *next* run — which is precisely why this hid for so long.
Also logs which pod reported which button. A pod reporting a button that
belongs to the other one is invisible downstream, because the input
carries the button and never its source.
Fixes both platforms at once: this is `crates/ble`, and Android is the
worse case — a leaked link there survives the app being swiped away.
Verified with four connect/disconnect cycles against the `−` pod: every
button registers on its documented bit (D-pad 0-3, paddle 8), and BlueZ
holds nothing afterwards. That satisfies TASK-0(d) and retires RISK-9.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A new HRM client in the BLE crate follows the crate's split: the 0x2A37
decoder is a pure function over bytes (u8/u16 formats, the three-state
sensor-contact field, straps that append energy/RR data), and only the
actor touches the radio. Anything exposing the standard Heart Rate
Service works — a chest strap, or a Garmin watch with Broadcast Heart
Rate on.
A single-slot supervisor in the app owns the link, shaped like the
trainer's and the controller's. It publishes bpm on a watch channel the
session backend stamps onto each tick's telemetry — never over a heart
rate FTMS itself reported, on the same authority rule as the Zwift
cadence merge — and it clears the reading after eight silent seconds,
so a strap taken off records nothing rather than a flatline of the
last real value. From there the existing pipeline does the rest: ride
screen tile, FIT records, avg/max in lap and session.
The device list routes heart-rate rows to the supervisor, keeps the row
alive while connected (a connected monitor stops advertising), and
shows the live bpm as proof data is flowing — a connected-but-silent
monitor otherwise looks exactly like a working one.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Chasing a paddle that shifted on one platform and not the other cost
several rebuilds, and most of that was spent unable to tell two very
different situations apart: a pod that was sending nothing, and a pod
whose frames we were receiving and quietly discarding. The logs looked
identical because every decoder in this path fails by dropping.
`click.rs` now logs every notification with its length and bytes before
anything tries to interpret it, and the unhandled-frame line in
`controller.rs` carries the payload rather than just the type byte —
which is the least useful part of a frame you could not parse, since it
is usually the framing that is wrong and not the content.
The default log filter gains `bikecontrol_ble=debug`. It was `info`,
which silenced the entire crate that owns every BLE conversation —
subscribe failures included. Survivable on desktop where RUST_LOG can
override it; not on Android, which has no environment to set and is
exactly where the subscribe was failing.
Adds `probe listen`, which decodes nothing on purpose: the GATT tree with
descriptors (the CCCD value is where a subscribe goes wrong, and
`inspect` stops short of it), then a subscribe to every notifying
characteristic across all services, reporting each as ok or FAILED. Each
notification prints characteristic, length and raw bytes, and the summary
names the characteristics that stayed silent — the difference between a
quiet device and listening in the wrong place.
That last part is what settled this one: it recorded 304 button frames
from a pod the app was reporting as dead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Gears are metres per crank revolution rather than gradient offsets, and
resistive_force_n exposes what the road is doing at a given speed so load
can be computed directly instead of servoed.
The load model is tested but not yet commanded: FTMS sim mode has the
trainer compute rolling and aero itself, so sending a gradient that already
contains them would double-count. Needs Crr/Cw zeroed and a ride to verify.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>