19 Commits
Author SHA1 Message Date
dtourolleandClaude Opus 5 c1c6ca390c Read the rejection properly: it was a command id, not a key refusal
Makinolo's Zwift Ride write-up gives the client command format — `00`
then protobuf field 1 with the parameter, so `00 08 00` is the
information request and `00 08 82 06` is parameter 770. Which explains
what the pod has been telling us all along.

We wrote frames beginning `0xff`. The pod answered `3e 08 ff 01 10 05` —
`{1: 255, 2: 5}`. **255 is 0xff**: our own first byte, echoed back as the
command id, with a status. It was never rejecting a key exchange; it was
saying "command 255, unsupported". `0xff` is a device-to-app notification
type and we were writing it back as though it were a command.

The same write-up records that Zwift "got rid of the Bluetooth
communication encryption they were using for the Play and the Click" —
and the Click v2 is newer than the Ride. So the crypto gate this line of
work assumed may not exist at all, which fits the plain fact that the
cleartext buttons work for the first fifty seconds.

That reopens A-2 from a better angle. It calls the thing that removes the
daily unlock a **keep-alive**: a periodic message, not a credential. So
`--keepalive <secs>` sends a chosen frame on a timer for the whole run
and lets the paddle oracle answer, and `info` and `param770` are
variants — the two commands the write-up documents, in the shape it
documents them.

If a periodic `00 08 00` holds the paddles open past the cliff, the fix
is a heartbeat in the controller supervisor and no cryptography at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 19:53:43 +02:00
dtourolleandClaude Opus 5 34861e4e04 The pod answers the Play handshake, and declines it
`--sweep` against a recovered pod, and two findings worth more than the
thing it was built to test.

**The v2 speaks the documented Play handshake.** Sent `RideOn 01 02` plus
a raw 64-byte key — §3.5's format, no protobuf envelope, the one shape we
had never tried because the offer's protobuf framing made it look
irrelevant — and the pod replied `RideOn 02 03` followed by 64 **zero**
bytes. That is the Play reply shape with the key zeroed: it understood
the question and refused to answer it. The four protobuf variants all
drew `0x3e {1: 255, 2: 5}`.

**And the stream then went to all-zero frames**, button frames included,
at their usual rate. We can put the pod into a state where it emits
nothing but zeros. It recovers by itself.

Also recorded: the stuck state is not permanent. The pod that opened
three sessions already past the cliff, with the `+` paddle bit pinned in
its hello, came back clean — `flag=0`, idle bitmask, `−` paddle
reporting. The unit is sealed and was never opened.

The sweep's attribution is not sound and the commit does not pretend
otherwise: five writes inside six seconds, every reply in one burst at
+11.37 s. `--variant <name>` now sends exactly one frame per connection,
which is the only way to learn which one does what. Zero frames are
counted and named rather than scrolling past as `other`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 19:45:38 +02:00
dtourolleandClaude Opus 5 9ab5b5530b Sweep the handshake variants, since the pod answers back
The first two candidate runs looked like failures and were not. Buried in
them: a `0x3e` frame arriving 90 ms after our write, in both runs, never
otherwise — `{1: 255, 2: 5}`. The pod parsed what we sent and rejected it
with a reason. That is a feedback channel, and it turns this from
guessing into navigating.

Both candidates drew the *same* reason, so the field-2 marker is not what
it objects to. `--sweep` therefore sends every variant down one
connection and prints the reply to each: field 1 alone, the pod's own
trailer echoed back, an uncompressed 65-byte point, and the documented
2023 Play handshake verbatim (`RideOn 01 02` + a raw 64-byte key, no
protobuf at all) — which we had never actually tried, having assumed the
protobuf shape from the offer.

It needs no button presses. That matters now: this pod has stopped
reporting buttons entirely, so the paddle oracle the rest of the command
depends on is unavailable, and a sweep that reads only the reply code
still works.

Fuzzing a pod is not fuzzing a trainer. §2.3 refused unknown writes to
the D100 because it puts resistance under a rider; a Click has no
actuator and the worst it can do is ignore us. The OAD characteristics
stay untouched — those can brick a sealed unit.

Two corrections to the tool while here. Button frames were counted but
never printed, so an operator pressing into a silent terminal could not
tell a working run from a dead pod and reasonably concluded the latter.
And the cliff is now taken from an actual `flag 0 -> 1` transition rather
than the first sighting of a 1 — these runs opened with the flag already
set, the pod having kept that state across the reconnect, and reporting
"cliff at 2.3s" for it was a reading dressed as a measurement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 19:39:17 +02:00
dtourolleandClaude Opus 5 4e400cdd3b Ask the pod whether answering its key offer keeps the paddles alive
`probe unlock` — the experiment §2.3.3 ends on, not an implementation.

The pod offers a compressed P-256 point and gives up on us when we do not
answer; the paddle bits freeze while the D-pad keeps reporting. What a
working client writes back is the half no capture has, and the public
descriptions are all of the older Play hardware — different message
types, an uncompressed key, a different channel.

But the offer looks like the handshake we already know, moved into a
protobuf envelope: its field 2 is `0x02030000`, and `RESPONSE_START` —
the pod's confirmed cleartext reply marker — is `[0x02, 0x03]`. That
makes the client side a short list rather than a search, and the device
is a perfect oracle: either a paddle edge arrives after the cliff or one
does not, every run, in two minutes.

So the command sends one candidate per run — `ours` (00 09, what we
already write), `play` (01 02, the 2023 client marker), `echo` (02 03,
in case field 2 names the suite rather than the speaker) — and reports
HELD, FAILED or INCONCLUSIVE. Omitting `--candidate` answers nothing and
measures the cliff this pod actually has, which is the control every
result needs.

The verdict deliberately refuses to call a failure from silence: it needs
the D-pad still reporting while the paddles do not, because a pod nobody
touched proves nothing and a dropped link is void rather than negative.

Field 3 of the offer — 40 or 60 bytes, unexplained — is omitted from the
reply. If it is load-bearing no candidate will hold, and that is a
finding too.

p256 is a dependency of the probe alone. The app takes no crypto
dependency on a guess.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 19:19:29 +02:00
dtourolleandClaude Opus 5 bbd11757ee Give up the radio for the link, not for the search
Two failures on the tablet, one of them mine from an hour ago.

The real one first. A trainer that drops mid-ride could not get back:
its supervisor reconnects on its own (FR-1.10) and those attempts never
pass through `DeviceRegistry::connect`, so nothing suspended the device
list for them. On Android a GATT link that is discovering services while
a scan is running is killed by the platform, and the log has it exactly —
a reconnect at 13:07:18 dying with `Disconnected while discovering
services`, inside a list-scan session opened at 13:07:02.

My first fix was to suspend the list whenever the trainer was
mid-connect. That was drawn around the wrong thing. `Connecting` covers
the 15 s *search*, `Reconnecting` covers the backoff between attempts,
and against an asleep trainer those alternate for the whole of
RECONNECT_ATTEMPTS — so the list scan went off the air for minutes and
every other device starved with it. A pod that dropped could never be
seen again, which is what "the pods disconnect after 40 seconds" was.

The window that matters is narrower than either: connect, then discover
services. `scan::gatt_setup` marks it — an RAII guard taken by the
trainer, pod and heart rate paths the moment their search returns a
peripheral — and the device list yields only for that. Measured on the
tablet: 1.2 s of yielding for a heart rate connect, then straight back to
one session per 21 s.

Also: the "+ pod seen; the − pod speaks for the pair" line is logged once
per run of refusals rather than once per sighting. The device list
republishes several times a second and every pass re-reported a visible
pod — 274 identical lines in five minutes, burying the connect failures
the log was being read for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 13:24:39 +02:00
dtourolleandClaude Opus 5 e589086078 Stop the scanner throttling itself off the air
Android counts an app's scan *starts*: five in any thirty seconds and the
platform answers `SCAN_FAILED_SCANNING_TOO_FREQUENTLY` and stops
returning results. It does this silently as far as btleplug is concerned,
so the app goes on asking and simply stops being told about anything.

`scan_loop` opened a session every 2.9 s — a 2.5 s window plus 400 ms
idle. **Ten starts per thirty seconds, twice the limit, with nothing else
running.** Add the trainer's 15 s search or a pod's 20 s one, which
`click.rs` notes runs to the full timeout because a Click only advertises
after a button press, and the app spends much of its life muted. A rider
who wakes the pods first is doing exactly the thing that pushes the count
over, and then the trainer cannot be found — not because it is not
advertising, but because the app is no longer allowed to hear it.

Starting a scan is the expensive act, not running one, so hold the
session and sample it:

- scan.rs splits `scan` into `begin` / `peek` / `end`, sharing one
  describe-filter-rank path (`collect`). `scan` stays as the one-shot
  form for the probe tool.
- `scan_loop` opens one session per SCAN_WINDOW (now 20 s) and peeks
  every 700 ms, publishing each time. The radio starts a seventh as
  often and the list updates four times *quicker* than the old
  whole-pass cadence.
- The session is recycled rather than held forever: a new adapter each
  cycle is what drops peripherals that have left the room, so 20 s is
  how stale a departed device may look. That was ~3 s before, and it is
  the one thing this trade gives up.
- The sample loop selects on the scan switch, so a suspension still
  lands immediately. A connect suspends this loop precisely so the two
  do not fight over the radio, and a suspension that took twenty seconds
  to arrive would be no suspension at all.

Instrumented at the choke point every caller passes through, because
"the radio is busy" and "the peripheral is asleep" look identical from
outside: every session start logs the concurrent depth and how many
starts there have been in the last thirty seconds, and warns when either
number is a problem. Measured on the tablet after this change — one
start per ~21 s, `recent=2`, against a limit of five.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 13:05:53 +02:00
dtourolleandClaude Opus 5 3143e7e51c Put listen before the tests, where clippy can live with it
🚴 Build and Test BikeControl / Workspace tests (push) Successful in 21m48s
Build & Release / Run tests (push) Successful in 14m3s
🚴 Build and Test BikeControl / Android compile check (push) Failing after 10s
Build & Release / Build Linux (deb + AppImage) (push) Successful in 17m22s
Build & Release / Build Arch package (push) Successful in 29m45s
Build & Release / Build Android APK (push) Failing after 22s
Build & Release / Create release (push) Skipped
`probe listen` was appended to the end of commands.rs, which put it
after `mod tests` — `clippy::items_after_test_module`, denied by the
`-D warnings` in the workspace clippy job. Pure code motion: the listen
section moves up as a block, above the test module, and nothing else
changes.

This is the first commit whose clippy run can actually be observed. The
job has been in .gitea/workflows since the images were pinned, but the
image was never in the registry, so it has never run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 22:53:40 +02:00
dtourolleandClaude Opus 5 ff94b78375 Never ride a link we did not open, and put a shifter on the other pod
🚴 Build and Test BikeControl / Workspace tests (push) Failing after 0s
🚴 Build and Test BikeControl / Android compile check (push) Skipped
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>
2026-08-20 21:05:40 +02:00
dtourolleandClaude Opus 5 a0190095fb Close the link before the budget runs out, not after
🚴 Build and Test BikeControl / Workspace tests (push) Failing after 0s
🚴 Build and Test BikeControl / Android compile check (push) Skipped
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>
2026-08-20 20:21:31 +02:00
dtourolleandClaude Fable 5 3274928a5f Stream heart rate from a strap or watch into the ride and the FIT file
🚴 Build and Test BikeControl / Workspace tests (push) Failing after 0s
🚴 Build and Test BikeControl / Android compile check (push) Skipped
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>
2026-08-20 19:27:04 +02:00
dtourolleandClaude Opus 5 be046f341c Make the BLE layer say what it actually received
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>
2026-08-20 19:16:26 +02:00
dtourolleandClaude Opus 5 06b4635470 Build every artifact in a pinned image, not on my laptop
Two builder images, because the jobs genuinely need two distributions:
Ubuntu for the tests, the Linux bundle and the Android APK, and Arch for
the .pkg.tar.zst, since makepkg is Arch-specific. Both are built from
this repo (NFR-12) and pinned by tag in the workflows, so a Dockerfile
change only reaches CI once it has been pushed.

libdbus-1-dev is not incidental in the Ubuntu image: btleplug's Linux
backend is bluez-async over the dbus crate, so without it the workspace
does not build at all.

The per-commit Android job is a cargo check, not an APK. The full signed
build is ~15 minutes and runs only on tags; a one-minute check catches
what actually breaks — the JNI shim, droidplug, and any desktop-only API
that has crept into a shared crate.

Two invariants a compiler cannot see are checked there too, because both
fail silently: the app builds, installs, launches, and finds no trainer.
The JNI symbol in android.rs is matched by the runtime by name, so
renaming the Kotlin package compiles fine and simply never initialises
btleplug; and gen/ must stay untracked or the sync script quietly becomes
optional.

The Android versionCode carries a 1000 floor. `tauri android init` writes
1000 for 0.1.0 today, so anyone holding a locally built APK already has
that number installed, and a bare major/minor/patch code would be 100 —
a downgrade, which Android refuses outright.

The fmt check is advisory for now. The tree predates this workflow and
`cargo fmt --all` currently rewrites ~2000 lines across 28 files; making
it a gate here would mean landing a repo-wide reformat as a side effect
of adding CI. Run fmt in its own commit, then drop the continue-on-error.
The two clippy warnings that stood between the tree and a real
`-D warnings` gate are fixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 19:52:45 +02:00
dtourolle dff3dc8367 Fix flacky BLE connection to swift pannels 2026-08-05 18:25:16 +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 f2c4cb2120 Gearing as development, physics-derived load model, 105kg rider
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>
2026-08-05 15:46:07 +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 3a2a787b7d Add Svelte GUI, FIT encoder and README
Standalone binary embeds the frontend, avoiding the dev-server dependency
that made the window fail to load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 13:50:01 +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
dtourolleandClaude Opus 5 eb216dd9e6 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>
2026-08-05 13:11:32 +02:00