Commit Graph
13 Commits
Author SHA1 Message Date
dtourolleandClaude Opus 5 8964a0fd74 Pair the pod that works
Both pods, same bench, minutes apart, no writes:

  − pod   buttons stop at ~51 s, every session; flag 0 -> 1; 2 key offers
  + pod   133 s, 78 paddle edges, flag never flipped, no key offer at all

So the `−` pod is not the controller. It relays more when it works — all
ten buttons, its twin's included — but "when it works" is under a minute
without a Zwift blessing in the last day, and the `+` pod alone is a
complete shifter: its paddle shifts up, `Y` shifts down, both already
mapped. Shifting is what a ride cannot do without.

`take_plus_pod` becomes `take_minus_pod` — the same rule with the pods
exchanged — and the housekeeping, the connect guard and the
no-pod-named default follow it. Opening both is still what stops the `−`
pod reporting its own paddle, so it is still one link, just the other one.

The UI stops telling riders to press the pod that dies: the panel asks
for the `+` pod, the tile prefers it, and the `−` pod's row says what it
actually offers — all ten buttons, for about fifty seconds.

This settles A-2 from the other end too. The keep-alive that "removes the
daily unlock, but only for the right controller" removes nothing. The
right controller never needed it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 20:09:26 +02:00
dtourolleandClaude Opus 5 e8d384e6fc A keep-alive on the command channel does not hold the cliff
`--variant info --keepalive 10`, full run, paddles worked throughout the
first 51 seconds and not one button frame after.

  51.62s  last button frame
  51.75s  STATUS flag=1 timer=900
  61.51s  keep-alive #7 sent
  61.56s  0x3c reply, device metadata, as if nothing were wrong

So the command channel is real and usable — `00 08 00` returns name
`Zwift Click`, `0B-34C45903A18E`, version `B.0`, type byte `0x0B` and the
address — and the pod goes on answering it *after* the cliff while
refusing to report a single button.

Which is the sharpest statement of the fault yet: the link is healthy,
the device is responsive, and it withholds exactly one capability on a
timer. Whatever re-arms that is not a periodic message on this channel,
so A-2's "keep-alive" is either a different message or, as A-2 itself
says, something that only ever worked for the right-hand pod.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 20:00:01 +02:00
dtourolleandClaude Opus 5 b1e6c08d07 Settle it: the gate is a credential, not a format
One write, clean session, unambiguous. Key offer at +5.31 s, our
`RideOn 01 02` + 64-byte key out, the pod's `RideOn 02 03` + 64 **zero**
bytes back at +5.40 s, stream all zeros from +5.49 s. `play-rideon`
alone causes both effects the sweep had confused together.

The zero frames are seven bytes — the exact length of a button frame — so
the pod is emitting correctly-shaped frames with the contents blanked.
That is a refusal mode somebody implemented, not a crash.

Which closes the question this line of work was asking. The v2
understands the documented handshake, answers in the documented shape,
and returns a zeroed key. We tried the protobuf envelope four ways and
the Play format verbatim; the device declined all five, in two distinct
and deliberate ways. What the Zwift app presents and we cannot — a token,
a signature, or a correctly computed field 3 — is what is being checked,
and no amount of well-formed framing substitutes for it. It cannot be
inferred from the device half of the conversation, and the device half is
all anyone here has.

So: closed until someone captures a real unlock. One HCI snoop would
settle it; nothing short of that will. The workarounds stand — the `+`
pod, which reportedly never needed the blessing, or re-linking the `−`
pod inside its own ~50 s window.

The variant stays in the probe so the finding can be reproduced, with a
note that it reliably blanks the pod and costs a recovery wait.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 19:48:01 +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 135da73e92 Decode the pod's key-exchange frames, and retire A-3
A-3 said the v2 needs no encryption: the pod completes the handshake and
streams buttons in cleartext, and we have never sent a key. That is true
for about fifty seconds.

Captured on the tablet mid-ride and decoded here. The pod sends a `0xff`
`03 00` frame carrying a **compressed 33-byte P-256 point** in protobuf
field 1, a constant `0x02030000` in field 2, and 40 or 60 unexplained
bytes in field 3. It sends one shortly after connecting and another
**242 ms after the last button frame** — and in the same breath a `0xff`
`05 00` status frame flips a flag 0 → 1 and a value 15 → 900. We answer
neither, and from that moment the paddle byte of the button bitmask is
frozen at `ff` while the D-pad keeps reporting in cleartext.

So §2.3's open question — whether the daily Zwift unlock is needed on
this path — is answered: it is, and this is the mechanism. The note that
our unencrypted sessions "ran past three minutes" no longer holds either.

Also recorded: this is **not** the Play handshake of §3.5. That is
`RideOn 01 02` plus a raw 64-byte key on Sync RX; ours is a compressed
33-byte key inside protobuf on the async channel, with two fields the
Play write-up has no room for. Implementing that spec verbatim would be
implementing a different device.

And the reason no responder is written yet: every frame we have is
device → app. Nothing shows what a working client writes back, which is
exactly what a responder must send, and five frames of one direction will
not yield it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 18:36:14 +02:00
dtourolleandClaude Opus 5 41574f694e Record what the interface now promises
Six rows, each of which the last three commits either implement or were
written to stop being ambiguous about.

FR-7.4a says what "configurable" has to mean: reachable from the app,
validated, and durable. FR-7.4 has been a Must since the first draft and
was read as satisfied by a command that existed — which is how every
rider ended up on a 105 kg default.

FR-7.5a puts units on the display side of the line and keeps recordings
SI. FR-9.20 and FR-9.21 are the device screen: roles before radios, and
no MAC addresses. FR-9.22 and FR-9.23 are the ride screen: zones only
against a reference the rider gave, and gradient as a shape with the
next block announced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 20:15:18 +02:00
dtourolleandClaude Opus 5 7497a5d602 Reconnect to remembered hardware instead of pairing every launch
`remembered` was a HashSet inside DeviceRegistry, so it lasted exactly as
long as the process. Every launch started from nothing: find the trainer,
press Connect, find the strap, press Connect, and only then ride. FR-1.5
has been a Should since the beginning and was never actually true.

It is a file now — devices.json in the app data directory, written when a
link actually comes up rather than when Connect is pressed. A Connect the
hardware then refuses is not a pairing, and writing one down would mean a
trainer the rider gave up on getting chased on every launch afterwards.
Forgetting is recorded too, in its own list: absent means never seen,
forgotten means the rider looked at this device and said no, and
auto-connect has to keep honouring that on the next launch as well. The
file is advisory — a corrupt one costs auto-connect, never a ride.

Auto-connect is driven by the scan rather than fired once at startup. The
hardware is asleep at startup — a trainer wakes when the cranks turn, a
strap when it is put on (A-4) — so a remembered device is reconnected the
moment it advertises, through the same path the rider's own click takes,
scan suspension included. Bounded by AUTO_ATTEMPTS on an AUTO_RETRY
cooldown and cleared when the link comes up or the rider connects by
hand: an app that never stops trying can never honestly say it has
stopped (FR-1.11). A device disconnected by hand is left alone for the
rest of the session, since a disconnect that undoes itself two ticks
later is not a disconnect.

Pods now prefer the pod we know. Every Click advertises the same name and
the same type byte, so before this a rider whose partner was warming up
in the next room got whichever pod woke first. With nothing of that kind
remembered anything still goes, or there could never be a first pairing.

And the pair is one pod, not two. Confirmed on this hardware 2026-08-21:
pairing the − pod alone delivers all ten buttons, its twin's included —
which §2.3.1 had established for the frames but not for the pairing. So
take_plus_pod holds the + pod back while a known − pod may merely be
asleep, and connect_controller with no pod named means the − pod rather
than both. The wait is bounded by PLUS_GRACE, because a flat − pod should
cost the rider a D-pad and not a controller, and Buttons is untouched: it
is what makes the handover between the two configurations invisible.

Not yet tested against real hardware — nothing was advertising here. The
store, the retry budget and the pod-preference rules have unit tests, and
a seeded devices.json was confirmed to load and seed the − pod at launch,
but the connect path itself waits for a ride.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 19:36:34 +02:00
dtourolleandClaude Opus 5 87e97e104f Write down what the pods actually did
Four findings from an evening on the hardware, three of which contradict
what this document said.

The undocumented `0000010x` characteristics are TI OAD firmware update:
their `0x2901` descriptors read "Img Identify", "Img Block" and "OAD
Extended Control". The table called them "undocumented, silent so far",
which invites probing them to see what answers — and the answer is that
`Img Block` takes firmware. They are silent because OAD says nothing
until an update is running. Marked do-not-write.

A pod offering a public key does not mean it wants encryption. The `−`
pod sent a compressed P-256 point in the same session as 304 cleartext
button frames. That was read twice as "it has switched to the encrypted
path", and neither reading survived the capture it came from. The crypto
section now says so, because the mistake is an easy one and it cost an
evening.

TASK-0(d) is satisfied and RISK-9 is retired. Every button on both pods
registers on the bit §2.3.1 documents: D-pad 0-3 and `−` on bit 8 across
four cycles, `A`/`B`/`Y`/`Z` on 4-7 and `+` on bit 12 over 83 presses.
QZ's `wontfix` for the left `−` does not reproduce here, so OQ-10 needs
no answer and gradient control stays on the left pod.

The evening the left pod appeared dead was a SAF-9 violation, now §7.1.
A half-closed link streams battery and no buttons, which is
indistinguishable from broken hardware — and it was diagnosed as a dead
pod, a wrong bit map, mis-filed pods and a lapsed unlock before anyone
looked at what the previous run had failed to close.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 20:25:25 +02:00
dtourolleandClaude Opus 5 8fcee06d84 Write down what the Android target actually costs
FR-9.17 to FR-9.19 state the screen-size contract: size is measured, the
legibility floors are absolute, and touch is a first-class input.
NFR-12 to NFR-14 state what the build now guarantees — pinned images, no
source that exists only under gen/, and BLE Java locked to the crate.

Three corrections to the spec, all of them places where it described a
plan the code did not follow:

The stack table still named tauri-plugin-blec. The code uses btleplug
directly and has since the BLE crate was written; blec wraps it in its
own device model, which crates/ble would then have to be written against
twice. Recorded with the reason, not silently edited.

RISK-1 (btleplug's Android backend is the least mature part of the stack)
is partly retired rather than closed: the target builds, the Java is
version-locked and the JNI init is symbol-checked in CI. A scan and a
connect on real hardware are still unproven, so TASK-4 keeps that half
and says exactly what is left — install on a phone, find the D100 and
both pods, and survive a screen-off.

NFR-11 is honest about being coarser on Android. FLAG_KEEP_SCREEN_ON is
held for as long as the app is foregrounded, not scoped to the ride as
the desktop inhibitor is. It needs no permission and cannot leak, but it
is not what the requirement describes, so the requirement says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 19:53:11 +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
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