14 Commits
Author SHA1 Message Date
dtourolleandClaude Opus 5 89920e782d Point the config at 0.2.6
🚴 Build and Test BikeControl / Workspace tests (push) Successful in 16m34s
Build & Release / Run tests (push) Successful in 6m32s
🚴 Build and Test BikeControl / Android compile check (push) Successful in 3m36s
Build & Release / Build Linux (deb + AppImage) (push) Successful in 16m14s
Build & Release / Build Arch package (push) Successful in 31m49s
Build & Release / Build Android APK (push) Successful in 20m33s
Build & Release / Create release (push) Successful in 19s
Android refuses an APK whose versionCode is not greater than the
installed one, and 0.2.5 is on the tablet. 1000 + major*10000 +
minor*100 + patch puts 0.2.6 at 1206.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 20:11:49 +02:00
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 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 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 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 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 92ce4ba2ae Refuse to join the pair, rather than undoing it a second later
Connecting both pods is the configuration in which the `−` pod stops
reporting its own paddle (§2.3.1). `Cmd::Seen` has always known that and
declined; an explicit `Cmd::Connect` walked straight past it, and
housekeeping then closed the redundant link a second or so later — which
looks like the app handling the case and is not the same thing. On the
tablet, 2026-08-27 15:55:39, the pair was joined for 1.4 s and the `−`
pod did not report another press for the rest of the session, across two
fresh links and an app reinstall.

So the rule now lives on both paths. `auto` is still armed by the refused
request, so the fallback stands: the moment the `−` pod goes away, the
`+` pod is taken on its next advertisement.

And the UI stops offering the trap. The `+` pod's "Connect anyway" sat
next to a working controller, which put the one action that breaks
shifting a single tap from a rider hunting for a way to fix shifting. It
now reads "Held in reserve", which is what that pod is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 18:04:16 +02:00
dtourolleandClaude Opus 5 53e7cd05fc Recycle a pod link that goes mute, not only one born mute
A link can wedge *after* working. On the tablet, 2026-08-27: the − pod
connected at 15:46:29, carried sixty presses, and at 15:47:19 stopped
sending button frames altogether while still streaming battery every five
seconds. The link was up, the pod was answering, and not one press
arrived for the rest of the ride.

The §7.1 detector could not see it. Its test was
`buttons_this_link == 0` — a link that had ever carried a button was
exempt, on the reasoning that a healthy pod proves itself once and should
never be disturbed again. Exempt for life turned out to mean dead for the
ride.

So the clock runs from the last press rather than from the connect, and
falls back to the connect for a link that never carried one. The cost of
being wrong is unchanged and still real — a rider who genuinely has not
shifted for NO_INPUT_AFTER loses shifting for the few seconds a reconnect
takes — which is why the window stays longer than any climb's worth of
steady pedalling.

Automatic recovery is deliberately slow, because the supervisor cannot
tell a wedged pod from a rider who is not shifting. The rider can, so the
shifter tile's action while connected is now Reconnect: drop the link and
take it again, immediately, instead of waiting out the window.

Not the both-pods failure, which was the first suspicion and would have
been the better story — connecting both is what stops the − pod reporting
its own paddle. The log rules it out: one `pod connected`, for the − pod,
and no redundant link ever closed. Every `plus` in it is the − pod
relaying its twin's paddle over the mesh, which is the pair working as
designed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 17:53:58 +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 9b1749d959 Point the config at 0.2.1
Android refuses an APK whose versionCode is not greater than the
installed one, so a fix cannot reach a tablet that already carries
0.2.0 without this. 1000 + major*10000 + minor*100 + patch puts 0.2.1
at 1201.

Built and installed on the tablet with the release key, over the top of
0.2.0 rather than through an uninstall, so the rider's settings.json and
devices.json survive the update.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 13:05:53 +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
17 changed files with 1746 additions and 131 deletions
Generated
+149
View File
@@ -222,6 +222,12 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base16ct"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.21.7" version = "0.21.7"
@@ -308,6 +314,8 @@ dependencies = [
"bikecontrol-core", "bikecontrol-core",
"btleplug", "btleplug",
"futures", "futures",
"p256",
"rand_core",
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
@@ -641,6 +649,12 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]] [[package]]
name = "cookie" name = "cookie"
version = "0.18.1" version = "0.18.1"
@@ -724,6 +738,18 @@ version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
[[package]]
name = "crypto-bigint"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [
"generic-array",
"rand_core",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.7" version = "0.1.7"
@@ -893,6 +919,16 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"zeroize",
]
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.8" version = "0.5.8"
@@ -962,6 +998,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer", "block-buffer",
"crypto-common", "crypto-common",
"subtle",
] ]
[[package]] [[package]]
@@ -1103,6 +1140,25 @@ version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
[[package]]
name = "elliptic-curve"
version = "0.13.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
"base16ct",
"crypto-bigint",
"digest",
"ff",
"generic-array",
"group",
"hkdf",
"rand_core",
"sec1",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "embed-resource" name = "embed-resource"
version = "3.0.11" version = "3.0.11"
@@ -1212,6 +1268,16 @@ dependencies = [
"simd-adler32", "simd-adler32",
] ]
[[package]]
name = "ff"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
dependencies = [
"rand_core",
"subtle",
]
[[package]] [[package]]
name = "field-offset" name = "field-offset"
version = "0.3.6" version = "0.3.6"
@@ -1505,6 +1571,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [ dependencies = [
"typenum", "typenum",
"version_check", "version_check",
"zeroize",
] ]
[[package]] [[package]]
@@ -1637,6 +1704,17 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "group"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [
"ff",
"rand_core",
"subtle",
]
[[package]] [[package]]
name = "gtk" name = "gtk"
version = "0.18.2" version = "0.18.2"
@@ -1731,6 +1809,24 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hkdf"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [
"hmac",
]
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]] [[package]]
name = "html5ever" name = "html5ever"
version = "0.38.0" version = "0.38.0"
@@ -2706,6 +2802,16 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "p256"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
dependencies = [
"elliptic-curve",
"primeorder",
]
[[package]] [[package]]
name = "pango" name = "pango"
version = "0.18.3" version = "0.18.3"
@@ -2916,6 +3022,15 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "primeorder"
version = "0.13.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
dependencies = [
"elliptic-curve",
]
[[package]] [[package]]
name = "proc-macro-crate" name = "proc-macro-crate"
version = "1.3.1" version = "1.3.1"
@@ -3008,6 +3123,15 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
]
[[package]] [[package]]
name = "raw-window-handle" name = "raw-window-handle"
version = "0.6.2" version = "0.6.2"
@@ -3253,6 +3377,19 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sec1"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
"base16ct",
"der",
"generic-array",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "selectors" name = "selectors"
version = "0.36.1" version = "0.36.1"
@@ -3625,6 +3762,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "swift-rs" name = "swift-rs"
version = "1.0.7" version = "1.0.7"
@@ -5527,6 +5670,12 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.4" version = "0.2.4"
+110 -1
View File
@@ -200,6 +200,26 @@ button we have found drives them.
> so it is evidence of a wrong-way-round pair only while that pod has never sent its own — > so it is evidence of a wrong-way-round pair only while that pod has never sent its own —
> otherwise the connection screen asks the rider to swap a pair that is filed correctly. > otherwise the connection screen asks the rider to swap a pair that is filed correctly.
> **The `+` pod is the controller — measured 2026-08-27, and this reverses the note
> below.** Both pods on the same bench, minutes apart, `probe unlock` with no writes:
>
> | | `−` pod | `+` pod |
> |---|---|---|
> | button reporting stops | **~51 s**, every session | **never** — 133 s, 78 paddle edges |
> | status flag `0 → 1` | yes, at the cliff | **never flipped** |
> | key offers | 2 per session | **none** |
>
> The `−` pod does relay 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. The `+` pod
> alone is a complete shifter: its paddle shifts up and `Y` shifts down, both of which the
> app already maps. Shifting is the thing a ride cannot do without, so the app now pairs the
> `+` pod and the `−` pod is the substitute. `take_minus_pod` is the old rule with the pods
> exchanged; opening both is still the configuration that breaks the `−` pod's own paddle,
> so it is still one link.
>
> This also settles A-2 from the other end. The keep-alive that "removes the daily unlock,
> but only for the right controller" removes nothing — the right controller never needed it.
> **One link is the whole controller — confirmed 2026-08-21.** Pairing the `−` pod *alone* > **One link is the whole controller — confirmed 2026-08-21.** Pairing the `−` pod *alone*
> delivers all ten buttons on this hardware: its own paddle and D-pad, plus the `+` paddle and > delivers all ten buttons on this hardware: its own paddle and D-pad, plus the `+` paddle and
> face buttons relayed from its twin. There is nothing a second link adds, and there is one > face buttons relayed from its twin. There is nothing a second link adds, and there is one
@@ -239,7 +259,96 @@ open-ended and not obviously safe. **Virtual shifting is therefore emulated app-
> **three minutes** with button edges still arriving, so this path does not appear to hit > **three minutes** with button edges still arriving, so this path does not appear to hit
> that timeout — but a long ride is the real test. > that timeout — but a long ride is the real test.
**Encryption — not required.** ✅ **A-3 holds for the v2.** The pod completed the handshake ### 2.3.3 The `0xff` key-exchange frames — decoded 2026-08-27 ⚠ **A-3 is dead**
Captured on the tablet during a real ride. The `−` pod connected, streamed cleartext
buttons for **fifty seconds**, and then stopped reporting its paddles while the link stayed
up and battery frames continued. Two frames bracket the moment:
| Frame | Meaning |
|-------|---------|
| `ff 03 00` + protobuf | **key offer.** field 1 = 33 bytes, a *compressed* P-256 point (`02`/`03` prefix, fresh each session); field 2 = varint `0x02030000`, constant; field 3 = 40 or 60 bytes, contents unknown |
| `ff 05 00` + protobuf | **status.** field 93 nests: `.1` = device id ASCII, `.2` = flag **0 → 1**, `.3` = **15 → 900**, `.4` = 8, `.5` = 9, `.6` = a small counter |
The pod sent a key offer at +4 s, a **second** key offer 242 ms after the last button frame,
and the status flag flipped `0 → 1` with `.3` going `15 → 900` in the same breath. We never
answered either offer.
**So A-3 does not hold.** The claim was that the pod "streams button and battery events in
cleartext and the app has never sent a key" — true, but only for about a minute. The earlier
note that "our unencrypted sessions ran past three minutes" and the open question of whether
the daily unlock is needed on this path are both answered: it is, and this is the mechanism.
> **This is not the Play handshake of §3.5.** That one is `RideOn 01 02` + a *raw 64-byte*
> key written to Sync RX, answered by `RideOn 00 09` + 64 bytes. Our v2 offers a
> **compressed 33-byte** key inside protobuf on the **async** channel under type `0xff`, with
> two fields the Play description has no room for. Implementing Makinolo's spec verbatim
> would be implementing a different device's protocol.
> **The v2 answers the Play handshake — 2026-08-27.** Sent `RideOn 01 02` plus a raw
> 64-byte key (the §3.5 format, no protobuf envelope), the pod replied
> `52 69 64 65 4f 6e 02 03` followed by **64 zero bytes** — the Play reply shape, with the
> key zeroed. The four protobuf-shaped variants all drew `0x3e {1: 255, 2: 5}` instead. So
> the device understands the documented handshake and declines to complete it, which is a
> refusal rather than a misunderstanding.
>
> Immediately afterwards the notification stream went to **all-zero frames** — including the
> 7-byte button frames, at their usual ~10 Hz. Whether that is the pod encrypting against a
> key it never agreed, or a firmware path nobody meant to reach, is unknown. It is
> recoverable: the pod came back on its own by the next session.
>
> **Settled with one write, 2026-08-27.** `--variant play-rideon` on a clean session: key
> offer at +5.31 s, our `RideOn 01 02` + 64-byte key sent, the pod's `RideOn 02 03` + 64
> zero bytes back at +5.40 s, and the stream all zeros from +5.49 s. That single frame
> causes both. The zero frames are **7 bytes** — the exact length of a button frame — so the
> pod is emitting correctly-shaped frames with the contents blanked, which is a refusal
> mode rather than a crash.
>
> **The gate is a credential, not a format.** The v2 understands the handshake, answers in
> the right shape, and returns a zeroed key. No framing we can construct changes that: we
> tried the protobuf envelope four ways and the Play format verbatim, and the device
> declined all five in two distinct, deliberate ways. What the Zwift app presents that we
> cannot — an account token, a signed blob, or a correctly *computed* field 3 — is the
> thing being checked, and it cannot be inferred from the device half of the conversation.
>
> **So this line is closed until someone captures the app's side.** One HCI snoop of one
> real unlock would settle it; nothing short of that will. Until then the workarounds stand:
> the `+` pod, which reportedly never needed the blessing, or re-linking the `−` pod inside
> its ~50 s window.
> **The command channel works; a keep-alive on it does not help — 2026-08-27.** Writing
> `00 08 00` gets a `0x3c` reply carrying device metadata: name `Zwift Click`, `0B-34C45903A18E`,
> version `B.0`, type byte `0x0B`, address. Sent every 10 s across a full run, the pod
> answered every one — **including after the cliff**, while refusing to report a single
> button. Last button frame 51.62 s, `flag=1 timer=900` at 51.75 s, nothing after.
>
> That is the sharpest framing of the fault so far: the link is healthy, the device is
> responsive, and it is withholding *one capability* on a timer. A periodic message is not
> what re-arms it.
> **Do not send `play-rideon` casually.** It reliably blanks the pod's output stream and
> costs a recovery wait. It is kept in the probe because reproducing a finding matters, not
> because it is safe to leave running.
> **Attribution is not yet sound.** All five variants went out inside six seconds and every
> reply arrived in one burst at +11.37 s, so which write triggered the zeros is not
> established. `probe unlock --variant <name>` sends exactly one per connection, which is
> how that gets settled.
> **The stuck state is not permanent.** A pod that had opened three consecutive sessions
> already past the cliff — `flag=1` from the first status frame, the escalated 60-byte
> offer, and a hello bitmask of `ffc3ffff1f` with the `+` paddle bit pinned — came back
> clean: `flag=0`, idle bitmask, `−` paddle reporting normally. No battery pull; the unit is
> sealed and was never opened.
> **The missing half.** Every frame above is device → app. Nothing in any capture shows what
> a *working* client writes back, and that is precisely what a responder has to send. It
> cannot be inferred from these five frames. Getting it means capturing both directions of a
> real unlock — Android's Bluetooth HCI snoop log against the official Zwift app — which is
> the next action, not more guessing.
**Encryption — was thought not required.** ⚠ **Superseded by §2.3.3; A-3 held only for the
first minute of a session.** The pod completed the handshake
and streamed button and battery events in cleartext, and the app has never sent a key. The and streamed button and battery events in cleartext, and the app has never sent a key. The
ECDH P-256 → HKDF → AES-256-CCM path documented below is therefore *not* needed, and no ECDH P-256 → HKDF → AES-256-CCM path documented below is therefore *not* needed, and no
crypto crate has been added. crypto crate has been added.
+3
View File
@@ -324,6 +324,9 @@ async fn open_session(
in_flight: &InFlight, in_flight: &InFlight,
) -> Result<(Session, Notifications), FtmsError> { ) -> Result<(Session, Notifications), FtmsError> {
let peripheral = find_pod(adapter, selector, config.scan_timeout).await?; let peripheral = find_pod(adapter, selector, config.scan_timeout).await?;
// The GATT window — see `scan::gatt_setup`. Scanners stay off the air until
// the session is built.
let _gatt = scan::gatt_setup();
// Cancelling past this point would otherwise strand the link (FR-1.10). // Cancelling past this point would otherwise strand the link (FR-1.10).
in_flight.hold(peripheral.clone()); in_flight.hold(peripheral.clone());
+4
View File
@@ -1321,6 +1321,10 @@ async fn connect_session(
}); });
let peripheral = scan::find_peripheral(adapter, selector, config.scan_timeout).await?; let peripheral = scan::find_peripheral(adapter, selector, config.scan_timeout).await?;
// Found. Everything from here to the end of service discovery is the window
// Android kills a link for if anything else is scanning, so say so — the
// device list watches this and stays off the air until it is over.
let _gatt = scan::gatt_setup();
// From here until this function returns, cancelling the caller is the only // From here until this function returns, cancelling the caller is the only
// thing that can leave a link open with nobody to close it. Hand the // thing that can leave a link open with nobody to close it. Hand the
// peripheral over now, before `connect()` — a cancellation lands wherever it // peripheral over now, before `connect()` — a cancellation lands wherever it
+2
View File
@@ -378,6 +378,8 @@ async fn open_session(
|d| selector.matches(d), |d| selector.matches(d),
) )
.await?; .await?;
// The GATT window — see `scan::gatt_setup`.
let _gatt = scan::gatt_setup();
// Cancelling past this point would otherwise strand the link (FR-1.10). // Cancelling past this point would otherwise strand the link (FR-1.10).
in_flight.hold(peripheral.clone()); in_flight.hold(peripheral.clone());
+152 -7
View File
@@ -1,7 +1,9 @@
//! BLE discovery (FR-1.1, FR-1.2). //! BLE discovery (FR-1.1, FR-1.2).
use std::collections::HashMap; use std::collections::{HashMap, VecDeque};
use std::time::Duration; use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use btleplug::api::{Central, Manager as _, Peripheral as _, ScanFilter}; use btleplug::api::{Central, Manager as _, Peripheral as _, ScanFilter};
use btleplug::platform::{Adapter, Manager, Peripheral, PeripheralId}; use btleplug::platform::{Adapter, Manager, Peripheral, PeripheralId};
@@ -114,24 +116,165 @@ impl ScanKind {
} }
} }
/// How many discovery sessions this process currently has open.
///
/// The adapter has **one** radio, and four independent supervisors ask it to
/// discover: the device list (`devices::scan_loop`, a 2.5 s pass every ~2.9 s),
/// the trainer client (15 s), the controller (20 s per pod attempt, up to 30
/// attempts), and the heart rate monitor. Only the first two are coordinated —
/// `DeviceRegistry::connect` suspends the list scan for a trainer connect
/// precisely so the two do not fight (devices.rs) — and a pod search routinely
/// runs its full 20 s because a Click only advertises after a button press.
///
/// So this counts. Every start and stop below logs the depth and who is
/// holding it; a line reading `depth=2` names both competitors and is the
/// evidence that a failed connect was a contended radio rather than a sleeping
/// peripheral. Diagnostic only — nothing here changes what the radio does.
static DISCOVERY_DEPTH: AtomicUsize = AtomicUsize::new(0);
/// Android's rule, and the one this app kept breaking: five scan *starts* in
/// any thirty seconds and the platform stops returning results.
const THROTTLE_WINDOW: Duration = Duration::from_secs(30);
const THROTTLE_STARTS: usize = 5;
/// When each recent session was opened, for the rate check below.
static STARTS: Mutex<VecDeque<Instant>> = Mutex::new(VecDeque::new());
/// Announce a discovery session opening, and return the depth including it.
///
/// Logs two different problems, because they have different fixes: `depth > 1`
/// is two searches sharing one radio, and a start rate at the platform's limit
/// is the app about to be muted by Android whether or not anything is sharing.
fn discovery_opened(who: &str) -> usize {
let depth = DISCOVERY_DEPTH.fetch_add(1, Ordering::SeqCst) + 1;
let recent = {
let now = Instant::now();
let mut starts = STARTS.lock().unwrap_or_else(|e| e.into_inner());
while starts.front().is_some_and(|t| now.duration_since(*t) > THROTTLE_WINDOW) {
starts.pop_front();
}
starts.push_back(now);
starts.len()
};
if recent >= THROTTLE_STARTS {
tracing::warn!(
who,
recent,
depth,
"discovery: {recent} scan starts in the last 30 s — at Android's limit, \
where further scans return nothing at all"
);
} else if depth > 1 {
tracing::warn!(
who,
depth,
recent,
"discovery: sharing the radio with another search — connects may time out"
);
} else {
tracing::debug!(who, depth, recent, "discovery: start");
}
depth
}
fn discovery_closed(who: &str, outcome: &str) {
let depth = DISCOVERY_DEPTH.fetch_sub(1, Ordering::SeqCst).saturating_sub(1);
tracing::debug!(who, depth, outcome, "discovery: stop");
}
/// How many links are being built right now.
///
/// The distinction that matters on Android: two *scans* overlapping is
/// wasteful, but a scan overlapping a **GATT setup** — connect, then discover
/// services — is what the platform kills, with `Disconnected while discovering
/// services`. So this marks that narrower window, and the device list stays off
/// the air only for it.
///
/// Not the search that precedes it. A trainer that is asleep is searched for
/// every few seconds for minutes on end (`RECONNECT_ATTEMPTS`), and suspending
/// the list scan for all of that starves every *other* device of the discovery
/// it needs — a dropped pod could never be seen again, which is exactly what
/// happened on the tablet when the suspension was drawn around the whole
/// reconnect instead of around this.
static GATT_SETUP: AtomicUsize = AtomicUsize::new(0);
/// Marks a link as under construction until dropped. See [`GATT_SETUP`].
#[must_use = "the window lasts as long as the guard is held"]
pub struct GattSetup(());
pub fn gatt_setup() -> GattSetup {
GATT_SETUP.fetch_add(1, Ordering::SeqCst);
GattSetup(())
}
impl Drop for GattSetup {
fn drop(&mut self) {
GATT_SETUP.fetch_sub(1, Ordering::SeqCst);
}
}
/// True while any link is being built. Scanners must stay off the air.
pub fn gatt_setup_active() -> bool {
GATT_SETUP.load(Ordering::SeqCst) > 0
}
/// Open a discovery session and leave it open.
///
/// Paired with [`end`], and sampled meanwhile with [`peek`]. The three exist
/// separately because **starting a scan is the expensive act on Android**, not
/// running one: since Android 7 the platform counts an app's scan *starts* and
/// refuses it with `SCAN_FAILED_SCANNING_TOO_FREQUENTLY` after five in thirty
/// seconds — silently, as far as btleplug is concerned, so the app goes on
/// asking and simply stops being told about anything.
///
/// A caller that wants a live list therefore has to hold one session and
/// sample it, rather than restart one per refresh. See `devices::scan_loop`.
pub async fn begin(adapter: &Adapter, kind: ScanKind, who: &str) -> Result<(), FtmsError> {
adapter.start_scan(kind.filter()).await?;
discovery_opened(who);
Ok(())
}
/// Everything the open session has seen so far. Does not touch the session.
pub async fn peek(adapter: &Adapter, kind: ScanKind) -> Result<Vec<DiscoveredDevice>, FtmsError> {
let peripherals = adapter.peripherals().await?;
Ok(collect(peripherals, kind).await)
}
/// Close a session opened by [`begin`]. Best effort: a failure to stop is worth
/// a line in the log and nothing more, since the next start subsumes it.
pub async fn end(adapter: &Adapter, who: &str, outcome: &str) {
if let Err(e) = adapter.stop_scan().await {
tracing::debug!(error = %e, "stop_scan failed");
}
discovery_closed(who, outcome);
}
/// Scan for `duration` and return everything seen. /// Scan for `duration` and return everything seen.
/// ///
/// Devices may be asleep (A-4) — a trainer often does not advertise until it is /// Devices may be asleep (A-4) — a trainer often does not advertise until it is
/// pedalled. An empty result means "nothing was advertising", not "no such /// pedalled. An empty result means "nothing was advertising", not "no such
/// device exists"; FR-1.8 requires the UI to say so. /// device exists"; FR-1.8 requires the UI to say so.
///
/// One session per call, so this is for one-shot callers — the probe tool, a
/// test. Anything refreshing on a timer wants [`begin`]/[`peek`]/[`end`].
pub async fn scan( pub async fn scan(
adapter: &Adapter, adapter: &Adapter,
duration: Duration, duration: Duration,
kind: ScanKind, kind: ScanKind,
) -> Result<Vec<DiscoveredDevice>, FtmsError> { ) -> Result<Vec<DiscoveredDevice>, FtmsError> {
adapter.start_scan(kind.filter()).await?; begin(adapter, kind, "device list").await?;
tokio::time::sleep(duration).await; tokio::time::sleep(duration).await;
let peripherals = adapter.peripherals().await?; let peripherals = adapter.peripherals().await?;
// Stopping the scan is best effort; a failure here must not lose results. end(adapter, "device list", "listed").await;
if let Err(e) = adapter.stop_scan().await {
tracing::debug!(error = %e, "stop_scan failed"); Ok(collect(peripherals, kind).await)
} }
/// Describe, filter and rank what the radio handed back.
async fn collect(peripherals: Vec<Peripheral>, kind: ScanKind) -> Vec<DiscoveredDevice> {
let mut out = Vec::with_capacity(peripherals.len()); let mut out = Vec::with_capacity(peripherals.len());
for p in peripherals { for p in peripherals {
if let Some(d) = describe(&p).await { if let Some(d) = describe(&p).await {
@@ -143,7 +286,7 @@ pub async fn scan(
} }
} }
out.sort_by(|a, b| b.rssi.unwrap_or(i16::MIN).cmp(&a.rssi.unwrap_or(i16::MIN))); out.sort_by(|a, b| b.rssi.unwrap_or(i16::MIN).cmp(&a.rssi.unwrap_or(i16::MIN)));
Ok(out) out
} }
/// Convenience wrapper: scan the default adapter for trainers. /// Convenience wrapper: scan the default adapter for trainers.
@@ -261,6 +404,7 @@ pub async fn find_matching(
matches: impl Fn(&DiscoveredDevice) -> bool, matches: impl Fn(&DiscoveredDevice) -> bool,
) -> Result<Peripheral, FtmsError> { ) -> Result<Peripheral, FtmsError> {
adapter.start_scan(kind.filter()).await?; adapter.start_scan(kind.filter()).await?;
discovery_opened(what);
let deadline = tokio::time::Instant::now() + timeout; let deadline = tokio::time::Instant::now() + timeout;
let poll = Duration::from_millis(400); let poll = Duration::from_millis(400);
@@ -291,6 +435,7 @@ pub async fn find_matching(
if let Err(e) = adapter.stop_scan().await { if let Err(e) = adapter.stop_scan().await {
tracing::debug!(error = %e, "stop_scan failed"); tracing::debug!(error = %e, "stop_scan failed");
} }
discovery_closed(what, if found.is_some() { "matched" } else { "timed out" });
found.ok_or_else(|| FtmsError::NotFound(what.to_string())) found.ok_or_else(|| FtmsError::NotFound(what.to_string()))
} }
+6
View File
@@ -18,3 +18,9 @@ uuid = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
tracing-subscriber = { workspace = true } tracing-subscriber = { workspace = true }
# Protocol discovery only (§9): the pod offers a compressed P-256 point and we
# need a real one to answer with. Pure Rust, and deliberately confined to the
# probe until an unlock candidate is proven — the app takes no crypto
# dependency on a guess.
p256 = { version = "0.13", default-features = false, features = ["ecdh", "arithmetic"] }
rand_core = { version = "0.6", features = ["getrandom"] }
+71 -1
View File
@@ -20,6 +20,14 @@ SUBCOMMANDS:
inspect <ADDR> Connect and dump every service, characteristic and capability inspect <ADDR> Connect and dump every service, characteristic and capability
monitor <ADDR> Stream Indoor Bike Data as raw hex alongside decoded fields monitor <ADDR> Stream Indoor Bike Data as raw hex alongside decoded fields
set <ADDR> <TARGET> Take control and apply a target, then reset the trainer to zero set <ADDR> <TARGET> Take control and apply a target, then reset the trainer to zero
unlock <ADDR> Answer the pod's key offer and see whether its paddles survive
past the ~50 s cliff (--candidate ours|play|echo; omit for a
control run that answers nothing). --sweep sends every known
variant in one connection and compares the pod's replies —
no button presses needed. --variant <name> sends exactly one
frame; --keepalive <secs> sends it on a timer all run
(variants: info, param770, compressed+ours, compressed+none,
compressed+trailer, uncompressed+ours, play-rideon)
zwift <ADDR> Talk to Zwift's custom service: handshake, then log every frame zwift <ADDR> Talk to Zwift's custom service: handshake, then log every frame
listen <ADDR> Raw GATT: dump services, characteristics and descriptors, then listen <ADDR> Raw GATT: dump services, characteristics and descriptors, then
subscribe to everything and print each notification's subscribe to everything and print each notification's
@@ -33,7 +41,7 @@ TARGET (for `set`):
OPTIONS: OPTIONS:
--secs <N> scan/monitor duration, or how long `set` holds the target (default: --secs <N> scan/monitor duration, or how long `set` holds the target (default:
scan 6, monitor 30, set 15, zwift 60) scan 6, monitor 30, set 15, zwift 60, unlock 150)
--all `scan`: list every peripheral, not just fitness machines --all `scan`: list every peripheral, not just fitness machines
--name <SUBSTR> use in place of <ADDR> to match on advertised name --name <SUBSTR> use in place of <ADDR> to match on advertised name
--no-handshake `zwift`, `listen`: subscribe and listen without writing RideOn. --no-handshake `zwift`, `listen`: subscribe and listen without writing RideOn.
@@ -89,6 +97,26 @@ pub enum Command {
/// until it is greeted, so listening alone hears silence from one. /// until it is greeted, so listening alone hears silence from one.
handshake: bool, handshake: bool,
}, },
/// Does answering the pod's key offer keep its paddles alive?
///
/// One candidate reply per run, then two minutes of watching the paddle
/// bits. See `unlock.rs` for what is being tested and why a guess is
/// affordable here.
Unlock {
device: Device,
duration: Duration,
/// Which field-2 marker to answer with; `None` sends nothing and is the
/// control run.
candidate: Option<String>,
/// Send every variant in one connection and compare the pod's replies.
/// Needs no button presses, so it works on a pod that has stopped
/// reporting them.
sweep: bool,
/// Send exactly one named variant, so its effect is unambiguous.
variant: Option<String>,
/// Send that variant on a timer for the whole run, rather than once.
keepalive: Option<Duration>,
},
/// Phase 3 / TASK-0: exercise Zwift's custom service on whatever advertises /// Phase 3 / TASK-0: exercise Zwift's custom service on whatever advertises
/// it — a Click, or the trainer itself. /// it — a Click, or the trainer itself.
Zwift { Zwift {
@@ -124,6 +152,10 @@ pub fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
let mut no_handshake = false; let mut no_handshake = false;
let mut buttons_only = false; let mut buttons_only = false;
let mut name: Option<String> = None; let mut name: Option<String> = None;
let mut candidate: Option<String> = None;
let mut sweep = false;
let mut variant: Option<String> = None;
let mut keepalive: Option<u64> = None;
let mut help = false; let mut help = false;
let mut positional: Vec<String> = Vec::new(); let mut positional: Vec<String> = Vec::new();
@@ -147,6 +179,34 @@ pub fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
.map_err(|_| anyhow!("--secs expects a whole number of seconds, got {v:?}"))?, .map_err(|_| anyhow!("--secs expects a whole number of seconds, got {v:?}"))?,
); );
} }
"--sweep" => sweep = true,
"--keepalive" => {
i += 1;
let v = args
.get(i)
.ok_or_else(|| anyhow!("--keepalive needs a value in seconds"))?
.clone();
keepalive = Some(
v.parse()
.map_err(|_| anyhow!("--keepalive expects whole seconds, got {v:?}"))?,
);
}
"--variant" => {
i += 1;
variant = Some(
args.get(i)
.ok_or_else(|| anyhow!("--variant needs a value"))?
.clone(),
);
}
"--candidate" => {
i += 1;
candidate = Some(
args.get(i)
.ok_or_else(|| anyhow!("--candidate needs a value"))?
.clone(),
);
}
"--name" => { "--name" => {
i += 1; i += 1;
name = Some( name = Some(
@@ -214,6 +274,16 @@ pub fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
duration: Duration::from_secs(secs.unwrap_or(60)), duration: Duration::from_secs(secs.unwrap_or(60)),
handshake: !no_handshake, handshake: !no_handshake,
}, },
"unlock" => Command::Unlock {
device: device(&positional, 1)?,
// Long enough to cross the ~50 s cliff twice over: a candidate that
// merely delays the failure must not read as one that fixed it.
duration: Duration::from_secs(secs.unwrap_or(150)),
candidate: candidate.clone(),
sweep,
variant: variant.clone(),
keepalive: keepalive.map(Duration::from_secs),
},
"zwift" => Command::Zwift { "zwift" => Command::Zwift {
device: device(&positional, 1)?, device: device(&positional, 1)?,
duration: Duration::from_secs(secs.unwrap_or(60)), duration: Duration::from_secs(secs.unwrap_or(60)),
+383 -1
View File
@@ -18,7 +18,7 @@ use bikecontrol_ble::indoor_bike_data::{self, hex, IndoorBikeData};
use bikecontrol_ble::scan::{self, DiscoveredDevice, ScanKind, TrainerSelector}; use bikecontrol_ble::scan::{self, DiscoveredDevice, ScanKind, TrainerSelector};
use bikecontrol_ble::{uuids, zwift, FtmsError}; use bikecontrol_ble::{uuids, zwift, FtmsError};
use bikecontrol_core::types::ControlTarget; use bikecontrol_core::types::ControlTarget;
use btleplug::api::{CharPropFlags, Characteristic, Peripheral as _}; use btleplug::api::{CharPropFlags, Characteristic, Peripheral as _, WriteType};
use btleplug::platform::Peripheral; use btleplug::platform::Peripheral;
use futures::StreamExt; use futures::StreamExt;
use uuid::Uuid; use uuid::Uuid;
@@ -724,6 +724,7 @@ pub async fn zwift_cmd(
// Only meaningful in --buttons mode: the mask as of the previous frame, so // Only meaningful in --buttons mode: the mask as of the previous frame, so
// the ~10 Hz repeat while a button is held collapses to one line. // the ~10 Hz repeat while a button is held collapses to one line.
let mut last_mask: Option<u32> = None; let mut last_mask: Option<u32> = None;
let mut presses: u64 = 0; let mut presses: u64 = 0;
loop { loop {
@@ -966,6 +967,387 @@ fn pressed(b: bool) -> &'static str {
} }
/// Name a UUID, checking Zwift's custom space as well as the SIG's. /// Name a UUID, checking Zwift's custom space as well as the SIG's.
/// `probe unlock` — answer the pod's key offer and see whether the paddles live.
///
/// The experiment, not an implementation: see `crate::unlock` for the
/// hypothesis and why one guess per run is affordable.
pub async fn unlock_cmd(
device: &Device,
duration: Duration,
candidate: Option<&str>,
sweep: bool,
variant: Option<&str>,
keepalive: Option<Duration>,
scan_timeout: Duration,
) -> Result<()> {
use crate::unlock;
let candidate = match candidate {
None => None,
Some(name) => Some(unlock::candidate(name).ok_or_else(|| {
anyhow::anyhow!(
"unknown candidate {name:?}. Known: {}",
unlock::CANDIDATES
.iter()
.map(|c| c.name)
.collect::<Vec<_>>()
.join(", ")
)
})?),
};
match candidate {
Some(c) => println!("Candidate {:?}: field 2 = 0x{:08x}\n {}\n", c.name, c.marker, c.why),
None => println!(
"Control run: answering nothing, to measure the cliff this pod actually has.\n"
),
}
let single = match variant {
None => None,
Some(name) => Some(unlock::variant(name).ok_or_else(|| {
anyhow::anyhow!(
"unknown variant {name:?}. Known: {}",
unlock::VARIANTS.iter().map(|v| v.name).collect::<Vec<_>>().join(", ")
)
})?),
};
match (single, keepalive) {
(Some(v), Some(every)) => println!(
"Keep-alive run: sending {} every {}s for the whole run.\n\
If the paddles are still reporting at the end, that is the fix.\n",
v.name,
every.as_secs()
),
(Some(v), None) => println!("Sending exactly one frame this run: {}\n", v.name),
(None, Some(_)) => anyhow::bail!("--keepalive needs --variant to say what to send"),
(None, None) => {}
}
let peripheral = connect(device, scan_timeout).await?;
if let Some(d) = scan::describe(&peripheral).await {
println!("Connected to {} ({})\n", d.address, d.label());
}
let service = zwift::SERVICES
.iter()
.find_map(|want| peripheral.services().into_iter().find(|s| s.uuid == *want))
.ok_or_else(|| anyhow::anyhow!("this peripheral exposes no known Zwift service"))?;
let mut notifications = peripheral.notifications().await?;
for ch in service
.characteristics
.iter()
.filter(|c| c.properties.intersects(CharPropFlags::NOTIFY | CharPropFlags::INDICATE))
{
if let Err(e) = peripheral.subscribe(ch).await {
println!("Could not subscribe to {}: {e}", ch.uuid);
}
}
let sync_rx = writable(&service)
.ok_or_else(|| anyhow::anyhow!("nothing in this service is writable — cannot answer"))?;
let start = Instant::now();
handshake(&peripheral, &sync_rx, &mut notifications, start).await?;
let local = unlock::local_key();
println!(
"Our P-256 point: {}\n\nWatching for {} s. Work the paddles and the D-pad throughout —\n\
the question is whether the paddles are still reporting at the end.\n",
hex(&local.compressed),
duration.as_secs()
);
let mut verdict = unlock::Verdict::new(start);
let mut answered = 0u32;
let mut offers = 0u32;
let mut last_status: Option<unlock::Status> = None;
// When the pod flipped its status flag, which is the cliff this run is
// measured against — better than a constant, because the pod says so.
let mut sent: Vec<&'static str> = Vec::new();
let mut responses: Vec<(&'static str, unlock::Response)> = Vec::new();
let mut zeros: u64 = 0;
let mut beats: u64 = 0;
let mut last_mask: Option<u32> = None;
// When the pod flipped its status flag, which is the cliff this run is
// measured against — better than a constant, because the pod says so.
let mut flip_at: Option<Duration> = None;
let deadline = tokio::time::sleep(duration);
tokio::pin!(deadline);
// A-2 calls the thing that removes the daily unlock a *keep-alive*. That is
// a periodic message, not a credential, and the Ride write-up gives the
// shape of one. So: send it on a timer and let the paddles answer.
let mut heartbeat = keepalive.map(|every| {
let mut t = tokio::time::interval(every);
t.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
t
});
loop {
tokio::select! {
_ = &mut deadline => break,
_ = tokio::signal::ctrl_c() => {
println!("\nInterrupted.");
break;
}
_ = async { heartbeat.as_mut().unwrap().tick().await }, if heartbeat.is_some() => {
let Some(v) = single else { continue };
let frame = (v.build)(&local, &unlock::KeyOffer::default());
let at = start.elapsed().as_secs_f32();
match write_frame(&peripheral, &sync_rx, &frame).await {
Ok(()) => {
beats += 1;
println!("[{at:7.2}s] KEEPALIVE #{beats} {} {}", v.name, hex(&frame));
}
Err(e) => println!("[{at:7.2}s] KEEPALIVE !! {e}"),
}
}
n = notifications.next() => {
let Some(n) = n else {
println!("\nThe device disconnected — the run is void, not a failure.");
break;
};
let at = start.elapsed().as_secs_f32();
if let Some(offer) = unlock::parse_key_offer(&n.value) {
offers += 1;
println!(
"[{at:7.2}s] KEY OFFER #{offers} key={} marker=0x{:08x} trailer={}B",
hex(&offer.public_key),
offer.marker,
offer.trailer.len()
);
match unlock::shared_secret(&local, &offer.public_key) {
// Logged, not used: agreeing a secret proves the point
// is real P-256, which is worth knowing before anyone
// writes a responder around it.
Ok(secret) => println!(
" ECDH agrees, shared secret starts {}",
hex(&secret[..8.min(secret.len())])
),
Err(e) => println!(" !! {e}"),
}
if keepalive.is_some() {
// The heartbeat is the experiment; firing again here
// would confound which write did what.
} else if let Some(one) = single {
// One write per connection. The sweep's replies came
// back in a single burst six seconds after the first
// write, so "attributed to the last thing sent" was a
// label, not a measurement. This is how you learn which
// frame does what.
let frame = (one.build)(&local, &offer);
println!("\n -> {:<20} {}", one.name, hex(&frame));
println!(" {}\n", one.why);
match write_frame(&peripheral, &sync_rx, &frame).await {
Ok(()) => {
answered += 1;
sent.push(one.name);
}
Err(e) => println!(" !! could not send: {e}"),
}
} else if sweep {
// One connection, every variant, because the pod hands
// back a reason for each. Spaced so a late reply cannot
// be attributed to the next thing we sent.
for v in unlock::VARIANTS {
let frame = (v.build)(&local, &offer);
println!("\n -> {:<20} {}", v.name, hex(&frame));
println!(" {}", v.why);
if let Err(e) = write_frame(&peripheral, &sync_rx, &frame).await {
println!(" !! could not send: {e}");
continue;
}
answered += 1;
sent.push(v.name);
tokio::time::sleep(Duration::from_millis(1200)).await;
}
println!();
} else if let Some(c) = candidate {
let frame = unlock::reply_frame(&local.compressed, c.marker);
match write_frame(&peripheral, &sync_rx, &frame).await {
Ok(()) => {
answered += 1;
println!(" answered with {}", hex(&frame));
}
Err(e) => println!(" !! could not answer: {e}"),
}
}
continue;
}
if let Some(status) = unlock::parse_status(&n.value) {
if last_status != Some(status) {
println!(
"[{at:7.2}s] STATUS flag={} timer={}",
status.flag, status.timer
);
// The pod telling us, in its own words, that whatever
// grace it was extending has ended. Everything before
// this is preamble; the run is only evidence from here.
// A *transition*, not merely a first sighting. These
// runs opened with flag already 1 — the pod remembers
// being past the cliff across reconnects — and calling
// that "the cliff at 2.3s" is a reading, not a fact.
let was_zero = last_status.is_some_and(|s| s.flag == 0);
if status.flag == 1 && was_zero && flip_at.is_none() {
flip_at = Some(start.elapsed());
println!(
"\n >>> THE CLIFF. Keep pressing both paddles and the D-pad for\n >>> another 60 s — everything before this line proves nothing.\n"
);
}
last_status = Some(status);
}
continue;
}
if let Some(r) = unlock::parse_response(&n.value) {
let to = sent.last().copied().unwrap_or("(unsolicited)");
println!("[{at:7.2}s] REPLY code={} detail={} <- {to}", r.code, r.detail);
responses.push((to, r));
continue;
}
if !n.value.is_empty() && n.value.iter().all(|b| *b == 0) {
zeros += 1;
if zeros == 1 {
println!(
"[{at:7.2}s] ZEROS the stream has gone to all-zero frames \
— counting from here"
);
}
continue;
}
if button_mask(&n.value).is_none()
&& unlock::parse_key_offer(&n.value).is_none()
&& unlock::parse_status(&n.value).is_none()
&& unlock::parse_response(&n.value).is_none()
{
println!("[{at:7.2}s] other {}", hex(&n.value));
}
if let Some(mask) = button_mask(&n.value) {
// Printed, not merely counted. An operator pressing buttons
// into a silent terminal cannot tell a working run from a
// dead pod, and will reasonably conclude the latter.
if last_mask != Some(mask.raw) {
last_mask = Some(mask.raw);
let paddles = mask.raw & ((1 << 8) | (1 << 12));
let which = match paddles {
p if p == (1 << 8) | (1 << 12) => "",
p if p & (1 << 8) == 0 => " <- − PADDLE",
_ => " <- + PADDLE",
};
println!(
"[{at:7.2}s] buttons 0x{:08x}{}{}",
mask.raw,
if mask.is_idle() { " (idle)" } else { "" },
which
);
}
verdict.observe(mask.raw);
}
}
}
}
// The pod's own flip where we saw it; otherwise the ~50 s the captures show.
let cliff = flip_at.unwrap_or(Duration::from_secs(50));
println!("\n=== verdict ===");
match (flip_at, last_status) {
(Some(t), _) => println!(" cliff (flag 0->1): {:.1}s", t.as_secs_f32()),
(None, Some(s)) if s.flag == 1 => println!(
" cliff: already past it when we connected — the pod kept\n \x20 that state across the reconnect"
),
_ => println!(" cliff: never flipped"),
}
println!(" key offers seen: {offers}");
if beats > 0 {
println!(" keep-alives sent: {beats}");
}
if zeros > 0 {
println!(" all-zero frames: {zeros} <- the stream stopped carrying data");
}
println!(" answered: {answered}");
println!(
" paddle edges: {} (last at {})",
verdict.paddle_edges,
verdict.last_paddle.map_or("never".into(), |t| format!("{:.1}s", t.as_secs_f32()))
);
println!(
" other edges: {} (last at {})",
verdict.other_edges,
verdict.last_other.map_or("never".into(), |t| format!("{:.1}s", t.as_secs_f32()))
);
if sweep {
println!("\n variant reply");
for (name, r) in &responses {
println!(" {name:<22} code={} detail={}", r.code, r.detail);
}
let distinct: std::collections::BTreeSet<_> =
responses.iter().map(|(_, r)| (r.code, r.detail)).collect();
if responses.is_empty() {
println!("\n The pod answered none of them, which is itself a change from\n the runs where it answered `ff 03 00` frames.");
} else if distinct.len() == 1 {
println!(
"\n Every variant drew the same reply, so none of the things varied —\n the marker, the trailer, the key encoding, the envelope — is what\n it is objecting to."
);
} else {
println!(
"\n The reply MOVED. Whichever variant differs is the thread to pull:\n that is the first time this device has told us we got warmer."
);
}
disconnect(&peripheral).await;
return Ok(());
}
if verdict.paddle_edges == 0 && verdict.other_edges == 0 {
println!(
"\n INCONCLUSIVE — no buttons at all. Press things during the run;\n\
a pod nobody touched proves nothing."
);
} else if verdict.paddles_look_dead(cliff) {
println!(
"\n FAILED — the D-pad still reports and the paddles stopped.\n\
That is the §2.3.3 signature, so this candidate did not hold them open."
);
} else if verdict.last_paddle.is_some_and(|t| t > cliff) {
println!(
"\n HELD — a paddle edge arrived {:.1}s past the cliff.\n\
Worth repeating before believing: run it again, and run the control.",
(verdict.last_paddle.unwrap() - cliff).as_secs_f32()
);
} else {
println!(
"\n INCONCLUSIVE — nothing was pressed after the cliff at {:.1}s.\n\
The run has to keep going, with fingers on the buttons, well past it.",
cliff.as_secs_f32()
);
}
disconnect(&peripheral).await;
Ok(())
}
/// Write to the pod, preferring write-without-response where offered.
async fn write_frame(
peripheral: &Peripheral,
ch: &Characteristic,
frame: &[u8],
) -> Result<(), btleplug::Error> {
let kind = if ch.properties.contains(CharPropFlags::WRITE_WITHOUT_RESPONSE) {
WriteType::WithoutResponse
} else {
WriteType::WithResponse
};
peripheral.write(ch, frame, kind).await
}
fn zwift_named(uuid: Uuid) -> String { fn zwift_named(uuid: Uuid) -> String {
zwift::well_known_name(uuid) zwift::well_known_name(uuid)
.map(|n| format!(" ({n})")) .map(|n| format!(" ({n})"))
+20
View File
@@ -11,6 +11,7 @@
mod cli; mod cli;
mod commands; mod commands;
mod unlock;
use std::time::Duration; use std::time::Duration;
@@ -54,6 +55,25 @@ async fn main() -> Result<()> {
duration, duration,
handshake, handshake,
} => commands::listen(&device, duration, handshake, SCAN_TIMEOUT).await, } => commands::listen(&device, duration, handshake, SCAN_TIMEOUT).await,
cli::Command::Unlock {
device,
duration,
candidate,
sweep,
variant,
keepalive,
} => {
commands::unlock_cmd(
&device,
duration,
candidate.as_deref(),
sweep,
variant.as_deref(),
keepalive,
SCAN_TIMEOUT,
)
.await
}
cli::Command::Zwift { cli::Command::Zwift {
device, device,
duration, duration,
+531
View File
@@ -0,0 +1,531 @@
//! `probe unlock` — does answering the pod's key offer keep the paddles alive?
//!
//! ## The question
//!
//! A Click v2 streams button state in cleartext for about fifty seconds and
//! then stops reporting its **paddles** — the D-pad keeps working, and the two
//! paddle bits of the bitmask freeze. Bracketing that moment, the pod sends a
//! `0xff 03 00` frame carrying a compressed P-256 public key, and flips a flag
//! in its `0xff 05 00` status frame (REQUIREMENTS §2.3.3). We never answer.
//!
//! The hypothesis this command tests: **the pod is asking for a key exchange
//! and giving up on us when we do not reply.** If so, replying keeps the
//! paddles alive past the cliff, and the shape of a working reply is the thing
//! we do not have — every capture is device → app.
//!
//! ## Why a guess is affordable here
//!
//! The v2's offer looks like the handshake we already know, moved into a
//! protobuf envelope. Its field 2 is the varint `0x02030000`, and
//! `zwift::RESPONSE_START` — the pod's confirmed cleartext reply marker — is
//! `[0x02, 0x03]`. That is not a coincidence, and it makes the client side a
//! short list rather than a search: our own marker (`0x00090000`), Play's
//! client marker (`0x01020000`), or the pod's own echoed back.
//!
//! And the device is a perfect oracle. Either the paddle bits still change at
//! T+120 s or they do not, and it says so every run, in two minutes, with no
//! APK and no tablet.
//!
//! ## What this is not
//!
//! Not an implementation. Nothing here derives a session key or decrypts
//! anything: it sends one candidate and watches. If a candidate holds the
//! paddles open, *then* the full ECDH → HKDF → AES-CCM responder is worth
//! writing, against a known-good handshake instead of a hopeful one.
use std::time::{Duration, Instant};
use anyhow::Result;
/// A candidate for the two-byte marker the client puts in field 2, carried in
/// the same big-endian-ish layout the pod uses for its own.
#[derive(Debug, Clone, Copy)]
pub struct Candidate {
pub name: &'static str,
pub marker: u32,
pub why: &'static str,
}
pub const CANDIDATES: &[Candidate] = &[
Candidate {
name: "ours",
marker: 0x0009_0000,
why: "the marker we already write in the confirmed cleartext handshake \
(zwift::REQUEST_START = 00 09)",
},
Candidate {
name: "play",
marker: 0x0102_0000,
why: "the client marker documented for the 2023 Play controllers (01 02)",
},
Candidate {
name: "echo",
marker: 0x0203_0000,
why: "the pod's own marker echoed back, in case field 2 names the suite \
rather than the speaker",
},
];
/// One thing to send, and what it is testing.
///
/// The sweep exists because the pod **answers**: a `0x3e` frame came back
/// 90 ms after our first write, in both runs, carrying `{1: 255, 2: 5}`. That
/// is a rejection with a reason, which makes this a conversation rather than a
/// guess — vary one thing, watch the reason move.
///
/// It needs no button presses, which matters: the pod this was written for has
/// stopped reporting buttons entirely, and the paddle oracle is unavailable
/// until it recovers.
pub struct Variant {
pub name: &'static str,
pub why: &'static str,
/// Built from our public key and, where it matters, the pod's own offer.
pub build: fn(&LocalKey, &KeyOffer) -> Vec<u8>,
}
/// Fuzzing a Click is not fuzzing a trainer. §2.3 refused to fuzz unknown
/// writes to the D100 because it puts resistance under a rider; a pod has no
/// actuator, and the worst it can do is ignore us. The OAD characteristics stay
/// untouched — those *can* brick it, and it is sealed.
pub fn variant(name: &str) -> Option<&'static Variant> {
VARIANTS.iter().find(|v| v.name == name)
}
/// A command, in the form the Zwift Ride protocol write-up documents: the byte
/// `0x00`, then protobuf field 1 carrying the parameter.
///
/// This is the shape we should have been writing all along. Our `0xff …` frames
/// were being read as *command 255*, and `0x3e {1: 255, 2: 5}` was the device
/// saying so — the command id echoed back with a status, not a rejected key.
pub fn command(param: u64) -> Vec<u8> {
let mut frame = vec![0x00];
field_varint(&mut frame, 1, param);
frame
}
pub const VARIANTS: &[Variant] = &[
Variant {
name: "info",
why: "the documented information request, `00 08 00` — if the command channel \
works at all, this is what proves it",
build: |_, _| command(0),
},
Variant {
name: "param770",
why: "`00 08 82 06`, the other documented command; 770 is 0x0302, which is the \
pod's own RideOn marker read as a number",
build: |_, _| command(770),
},
Variant {
name: "compressed+ours",
why: "what we have already sent twice — the control for the sweep",
build: |k, _| reply_frame(&k.compressed, 0x0009_0000),
},
Variant {
name: "compressed+none",
why: "field 1 alone, in case field 2 is the objection",
build: |k, _| {
let mut body = Vec::new();
field_bytes(&mut body, 1, &k.compressed);
envelope(body)
},
},
Variant {
name: "compressed+trailer",
why: "the pod's own field 3 echoed back — if the trailer is load-bearing, this is the cheapest way to find out",
build: |k, offer| {
let mut body = Vec::new();
field_bytes(&mut body, 1, &k.compressed);
field_varint(&mut body, 2, 0x0009_0000);
field_bytes(&mut body, 3, &offer.trailer);
envelope(body)
},
},
Variant {
name: "uncompressed+ours",
why: "a 65-byte SEC1 point, since the Play generation exchanged uncompressed keys",
build: |k, _| reply_frame(&k.uncompressed, 0x0009_0000),
},
Variant {
name: "play-rideon",
why: "the documented 2023 Play handshake verbatim: RideOn 01 02 + a raw 64-byte key, no protobuf envelope at all",
build: |k, _| {
let mut frame = Vec::with_capacity(72);
frame.extend_from_slice(b"RideOn");
frame.extend_from_slice(&[0x01, 0x02]);
// SEC1 uncompressed minus the 0x04 tag, which is how Play carried it.
frame.extend_from_slice(&k.uncompressed[1..]);
frame
},
},
];
/// `ff 03 00` around a protobuf body.
fn envelope(body: Vec<u8>) -> Vec<u8> {
let mut frame = Vec::with_capacity(body.len() + 3);
frame.extend_from_slice(&[0xff, 0x03, 0x00]);
frame.extend_from_slice(&body);
frame
}
/// The pod's answer to something we sent. `0x3e`, two varints.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Response {
pub code: u64,
pub detail: u64,
}
pub fn parse_response(raw: &[u8]) -> Option<Response> {
if raw.first() != Some(&0x3e) {
return None;
}
let mut i = 1;
let mut r = Response { code: 0, detail: 0 };
while i < raw.len() {
let tag = read_varint(raw, &mut i)?;
let v = match tag & 7 {
0 => read_varint(raw, &mut i)?,
_ => return None,
};
match tag >> 3 {
1 => r.code = v,
2 => r.detail = v,
_ => {}
}
}
Some(r)
}
pub fn candidate(name: &str) -> Option<Candidate> {
CANDIDATES.iter().find(|c| c.name == name).copied()
}
// ---------------------------------------------------------------------------
// Minimal protobuf, write side
// ---------------------------------------------------------------------------
fn varint(out: &mut Vec<u8>, mut v: u64) {
loop {
let byte = (v & 0x7f) as u8;
v >>= 7;
if v == 0 {
out.push(byte);
return;
}
out.push(byte | 0x80);
}
}
fn field_bytes(out: &mut Vec<u8>, field: u32, value: &[u8]) {
varint(out, u64::from(field) << 3 | 2);
varint(out, value.len() as u64);
out.extend_from_slice(value);
}
fn field_varint(out: &mut Vec<u8>, field: u32, value: u64) {
varint(out, u64::from(field) << 3);
varint(out, value);
}
/// The reply, shaped exactly like the offer we are answering.
///
/// `ff 03 00` then `{1: our compressed public key, 2: marker}`. Field 3 of the
/// pod's own offer — 40 or 60 bytes, unexplained — is deliberately omitted: if
/// it turns out to be load-bearing, no candidate will hold the paddles open and
/// that is itself the finding.
pub fn reply_frame(public_key: &[u8], marker: u32) -> Vec<u8> {
let mut body = Vec::with_capacity(48);
field_bytes(&mut body, 1, public_key);
field_varint(&mut body, 2, u64::from(marker));
let mut frame = Vec::with_capacity(body.len() + 3);
frame.extend_from_slice(&[0xff, 0x03, 0x00]);
frame.extend_from_slice(&body);
frame
}
// ---------------------------------------------------------------------------
// Minimal protobuf, read side
// ---------------------------------------------------------------------------
fn read_varint(b: &[u8], i: &mut usize) -> Option<u64> {
let mut v = 0u64;
let mut shift = 0;
loop {
let byte = *b.get(*i)?;
*i += 1;
v |= u64::from(byte & 0x7f) << shift;
if byte & 0x80 == 0 {
return Some(v);
}
shift += 7;
if shift > 63 {
return None;
}
}
}
/// The pod's key offer, as much of it as we can name.
#[derive(Debug, Clone, Default)]
pub struct KeyOffer {
/// Field 1 — 33 bytes, a compressed P-256 point.
pub public_key: Vec<u8>,
/// Field 2 — `0x02030000` on every capture so far.
pub marker: u64,
/// Field 3 — 40 or 60 bytes, meaning unknown.
pub trailer: Vec<u8>,
}
/// Recognise `ff 03 00` + protobuf. Returns `None` for anything else.
pub fn parse_key_offer(raw: &[u8]) -> Option<KeyOffer> {
if raw.len() < 4 || raw[0] != 0xff || raw[1] != 0x03 {
return None;
}
let mut i = 3;
let mut offer = KeyOffer {
public_key: Vec::new(),
marker: 0,
trailer: Vec::new(),
};
while i < raw.len() {
let tag = read_varint(raw, &mut i)?;
let (field, wire) = (tag >> 3, tag & 7);
match wire {
0 => {
let v = read_varint(raw, &mut i)?;
if field == 2 {
offer.marker = v;
}
}
2 => {
let len = read_varint(raw, &mut i)? as usize;
let end = i.checked_add(len)?;
let value = raw.get(i..end)?.to_vec();
i = end;
match field {
1 => offer.public_key = value,
3 => offer.trailer = value,
_ => {}
}
}
// Nothing in the captures uses the other wire types; bail rather
// than mis-parse and report a confident wrong answer.
_ => return None,
}
}
(!offer.public_key.is_empty()).then_some(offer)
}
/// The pod's status frame — `ff 05 00`, field 93 nested. `.2` flips 0 → 1 and
/// `.3` goes 15 → 900 as the paddles die (§2.3.3).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Status {
pub flag: u64,
pub timer: u64,
}
pub fn parse_status(raw: &[u8]) -> Option<Status> {
if raw.len() < 4 || raw[0] != 0xff || raw[1] != 0x05 {
return None;
}
let mut i = 3;
let tag = read_varint(raw, &mut i)?;
if tag >> 3 != 93 || tag & 7 != 2 {
return None;
}
let len = read_varint(raw, &mut i)? as usize;
let end = i.checked_add(len)?;
let inner = raw.get(i..end)?;
let mut j = 0;
let mut status = Status { flag: 0, timer: 0 };
while j < inner.len() {
let tag = read_varint(inner, &mut j)?;
let (field, wire) = (tag >> 3, tag & 7);
match wire {
0 => {
let v = read_varint(inner, &mut j)?;
match field {
2 => status.flag = v,
3 => status.timer = v,
_ => {}
}
}
2 => {
let len = read_varint(inner, &mut j)? as usize;
j = j.checked_add(len)?;
}
_ => return None,
}
}
Some(status)
}
// ---------------------------------------------------------------------------
// The verdict
// ---------------------------------------------------------------------------
/// Which bits the paddles occupy, confirmed 2026-08-05 (§2.3.1).
const PADDLE_MINUS: u32 = 1 << 8;
const PADDLE_PLUS: u32 = 1 << 12;
const PADDLES: u32 = PADDLE_MINUS | PADDLE_PLUS;
/// Tracks the one thing this experiment is for: are the paddles still alive?
pub struct Verdict {
start: Instant,
pub paddle_edges: u32,
pub last_paddle: Option<Duration>,
pub other_edges: u32,
pub last_other: Option<Duration>,
last_mask: Option<u32>,
}
impl Verdict {
pub fn new(start: Instant) -> Self {
Self {
start,
paddle_edges: 0,
last_paddle: None,
other_edges: 0,
last_other: None,
last_mask: None,
}
}
/// Feed a button bitmask. Only *changes* count, since the pod repeats at
/// ~10 Hz while anything is held.
pub fn observe(&mut self, mask: u32) {
let Some(previous) = self.last_mask.replace(mask) else {
return;
};
let changed = previous ^ mask;
let at = self.start.elapsed();
if changed & PADDLES != 0 {
self.paddle_edges += 1;
self.last_paddle = Some(at);
}
if changed & !PADDLES != 0 {
self.other_edges += 1;
self.last_other = Some(at);
}
}
/// The pod is *reachable* — the D-pad still reports — but the paddles have
/// gone quiet. That, and not a dropped link, is the failure being chased.
pub fn paddles_look_dead(&self, cliff: Duration) -> bool {
let alive_elsewhere = self.last_other.is_some_and(|t| t > cliff);
let paddles_quiet = self.last_paddle.is_none_or(|t| t <= cliff);
alive_elsewhere && paddles_quiet
}
}
/// A P-256 keypair, and the compressed point to put on the wire.
pub struct LocalKey {
pub secret: p256::ecdh::EphemeralSecret,
pub compressed: Vec<u8>,
/// SEC1 uncompressed, `04 ‖ X ‖ Y`, 65 bytes.
pub uncompressed: Vec<u8>,
}
pub fn local_key() -> LocalKey {
use p256::elliptic_curve::sec1::ToEncodedPoint;
let secret = p256::ecdh::EphemeralSecret::random(&mut rand_core::OsRng);
let public = secret.public_key();
let compressed = public.to_encoded_point(true).as_bytes().to_vec();
let uncompressed = public.to_encoded_point(false).as_bytes().to_vec();
LocalKey {
secret,
compressed,
uncompressed,
}
}
/// Best-effort ECDH against the pod's offered point, for the log. A key we
/// cannot agree on is a candidate we can stop testing.
pub fn shared_secret(local: &LocalKey, peer: &[u8]) -> Result<Vec<u8>> {
use p256::elliptic_curve::sec1::FromEncodedPoint;
let point = p256::EncodedPoint::from_bytes(peer)
.map_err(|e| anyhow::anyhow!("the pod's point is not a valid SEC1 encoding: {e}"))?;
let public = Option::<p256::PublicKey>::from(p256::PublicKey::from_encoded_point(&point))
.ok_or_else(|| anyhow::anyhow!("the pod's point is not on P-256"))?;
Ok(local
.secret
.diffie_hellman(&public)
.raw_secret_bytes()
.to_vec())
}
#[cfg(test)]
mod tests {
use super::*;
/// The five frames captured on the tablet, 2026-08-27.
const OFFER: &str = "ff03000a21026d059e761978c34f8a13eedbff764a34f0e48577d90fb5dea76ef6238eae8580108080\
8c101a285e71479dbe97b0c9bf3c754d594d67ca40792345b69a921905489ec5a3cd0b1e5bb5e36e8cb3e683";
fn bytes(h: &str) -> Vec<u8> {
(0..h.len())
.step_by(2)
.map(|i| u8::from_str_radix(&h[i..i + 2], 16).unwrap())
.collect()
}
#[test]
fn the_captured_offer_parses() {
let offer = parse_key_offer(&bytes(OFFER)).expect("captured frame should parse");
assert_eq!(offer.public_key.len(), 33);
// Compressed SEC1: 0x02 or 0x03 then the x coordinate.
assert!(matches!(offer.public_key[0], 0x02 | 0x03));
assert_eq!(offer.marker, 0x0203_0000);
assert_eq!(offer.trailer.len(), 40);
}
#[test]
fn the_status_flag_and_timer_are_read() {
// Before the paddles died, and after.
let before = bytes("ff0500ea05180a0c3334433435393033413138451000180f200828093020");
let after = bytes("ff0500ea05190a0c3334433435393033413138451001188407200828093020");
assert_eq!(parse_status(&before).unwrap(), Status { flag: 0, timer: 15 });
assert_eq!(parse_status(&after).unwrap(), Status { flag: 1, timer: 900 });
}
#[test]
fn a_button_frame_is_not_mistaken_for_a_key_offer() {
assert!(parse_key_offer(&bytes("2308ffffffff0f")).is_none());
assert!(parse_status(&bytes("2308ffffffff0f")).is_none());
}
#[test]
fn our_reply_is_shaped_like_the_offer_it_answers() {
let key = local_key();
assert_eq!(key.compressed.len(), 33);
let frame = reply_frame(&key.compressed, 0x0009_0000);
let echoed = parse_key_offer(&frame).expect("our own frame should parse");
assert_eq!(echoed.public_key, key.compressed);
assert_eq!(echoed.marker, 0x0009_0000);
assert!(echoed.trailer.is_empty());
}
/// The oracle: the D-pad still moving while the paddles do not is the
/// signature being chased, and neither silence alone nor a live paddle is.
#[test]
fn dead_paddles_need_a_live_d_pad_to_be_evidence() {
let start = Instant::now();
let cliff = Duration::from_secs(0);
let mut nothing_at_all = Verdict::new(start);
nothing_at_all.observe(0xffff_ffff);
assert!(!nothing_at_all.paddles_look_dead(cliff));
let mut d_pad_only = Verdict::new(start);
d_pad_only.observe(0xffff_ffff);
d_pad_only.observe(0xffff_fffe); // `left`
assert!(d_pad_only.paddles_look_dead(cliff));
let mut healthy = Verdict::new(start);
healthy.observe(0xffff_ffff);
healthy.observe(0xffff_fffe);
healthy.observe(0xffff_feff); // `−` paddle
assert!(!healthy.paddles_look_dead(cliff));
}
}
+4 -1
View File
@@ -795,8 +795,11 @@ pub fn connect_controller(
if address.is_some() { if address.is_some() {
return Err("An address names one pod, so say which pod it is".into()); return Err("An address names one pod, so say which pod it is".into());
} }
// The `+` pod, since 2026-08-27: it is the one that keeps
// reporting (§2.3.3). Naming no pod means "connect the
// controller", and the controller is the pod that works.
let known = state.lock().devices.click_pod_addresses(); let known = state.lock().devices.click_pod_addresses();
controller.connect(PodId::Minus, known.get(&PodId::Minus).cloned()); controller.connect(PodId::Plus, known.get(&PodId::Plus).cloned());
} }
} }
Ok(()) Ok(())
+139 -58
View File
@@ -62,13 +62,18 @@ const STALE_AFTER: Duration = Duration::from_secs(180);
/// How long a *live* link may go without ever carrying a button before we stop /// How long a *live* link may go without ever carrying a button before we stop
/// believing in it. /// believing in it.
/// ///
/// This is not "the rider has not shifted lately" — that is normal, and tearing /// The §7.1 case: frames arriving steadily, battery every five seconds, and not
/// down a working link over it would strand a pod that only advertises while /// one press in all that time. Recycling costs a few seconds against a pod that
/// awake. It is the narrower and much stranger case from §7.1: frames arriving /// is otherwise useless for the rest of the ride.
/// steadily, battery every five seconds, and not one press since the link came ///
/// up. A healthy pod proves itself with its first button and is then never /// It is measured from the last press rather than from the connect, because a
/// touched by this; a wedged one never does, and recycling costs a few seconds /// link can wedge *after* working — sixty presses and then nothing, tablet,
/// against a pod that is otherwise useless for the whole ride. /// 2026-08-27 — and an exemption earned by the first button was an exemption
/// for the whole ride.
///
/// The cost of being wrong is a rider who genuinely has not shifted for this
/// long losing shifting for the few seconds a reconnect takes, so the window is
/// deliberately longer than any climb's worth of steady pedalling.
const NO_INPUT_AFTER: Duration = Duration::from_secs(150); const NO_INPUT_AFTER: Duration = Duration::from_secs(150);
/// Upper bound on closing the controller links at exit. Shorter than the /// Upper bound on closing the controller links at exit. Shorter than the
/// trainer's: there is no reset sequence here, only an unsubscribe and a /// trainer's: there is no reset sequence here, only an unsubscribe and a
@@ -559,6 +564,9 @@ struct Slot {
/// frames arriving, and not one press among them. /// frames arriving, and not one press among them.
connected_at: Option<tokio::time::Instant>, connected_at: Option<tokio::time::Instant>,
buttons_this_link: u32, buttons_this_link: u32,
/// When this link last carried a press. `None` until it carries one, which
/// is why the silence test below falls back to `connected_at`.
last_button: Option<tokio::time::Instant>,
/// May this pod be connected the moment the scan sees it? /// May this pod be connected the moment the scan sees it?
/// ///
/// True until the rider disconnects it by hand, because a pod that /// True until the rider disconnects it by hand, because a pod that
@@ -575,6 +583,7 @@ impl Default for Slot {
events: None, events: None,
connected_at: None, connected_at: None,
buttons_this_link: 0, buttons_this_link: 0,
last_button: None,
cancel: None, cancel: None,
generation: 0, generation: 0,
last_seen: None, last_seen: None,
@@ -620,6 +629,9 @@ async fn run(
// back whenever the `−` pod is reachable, so it only ever expires against a // back whenever the `−` pod is reachable, so it only ever expires against a
// `−` pod that is genuinely not coming (see `PLUS_GRACE`). // `−` pod that is genuinely not coming (see `PLUS_GRACE`).
let mut plus_gate = tokio::time::Instant::now() + PLUS_GRACE; let mut plus_gate = tokio::time::Instant::now() + PLUS_GRACE;
// Whether the "+ pod is redundant" refusal has already been logged for the
// current state of affairs. See the `Seen` arm.
let mut plus_declined = false;
let (attempt_tx, mut attempt_rx) = mpsc::channel::<Attempt>(4); let (attempt_tx, mut attempt_rx) = mpsc::channel::<Attempt>(4);
let mut housekeeping = tokio::time::interval(Duration::from_secs(5)); let mut housekeeping = tokio::time::interval(Duration::from_secs(5));
@@ -634,10 +646,36 @@ async fn run(
}; };
match cmd { match cmd {
Cmd::Connect { pod, address } => { Cmd::Connect { pod, address } => {
// The rule `Cmd::Seen` has always enforced, applied
// here too: with the `−` pod up there is nothing for a
// `+` link to add and one specific thing for it to
// break — connected as a pair, the `−` pod stops
// reporting its own paddle (§2.3.1).
//
// Only the scan-driven path was guarded, so an explicit
// request walked straight past it. Housekeeping then
// closed the redundant link a second or so later, which
// reads as the app handling it and is not the same
// thing at all: on the tablet, 2026-08-27 15:55:39, the
// pair was joined for 1.4 s and the `−` pod did not
// report a press again for the rest of the session.
//
// `auto` is still armed below, so the fallback stands:
// the moment the `−` pod goes away, the `+` pod is
// taken on its next advertisement.
let plus_up = !plus.idle();
let slot = slot_mut(&mut minus, &mut plus, pod); let slot = slot_mut(&mut minus, &mut plus, pod);
// Asking for it by hand re-arms auto-connect, whatever // Asking for it by hand re-arms auto-connect, whatever
// came before. // came before.
slot.auto = true; slot.auto = true;
if pod == PodId::Minus && plus_up {
tracing::info!(
"controller: refusing a − pod link while the + pod is up — \
the + pod is the controller, and joining both stops the \
− pod reporting its own paddle anyway"
);
continue;
}
// A second click while a search is running means // A second click while a search is running means
// "connect", which is what is already happening. // "connect", which is what is already happening.
if slot.connecting() { if slot.connecting() {
@@ -651,7 +689,7 @@ async fn run(
let selector = selector_for(pod, address, &status_tx.borrow(), swapped); let selector = selector_for(pod, address, &status_tx.borrow(), swapped);
tracing::info!(pod = pod.as_str(), selector = %selector.describe(), "controller: connecting"); tracing::info!(pod = pod.as_str(), selector = %selector.describe(), "controller: connecting");
start_attempt(slot, pod, selector, &attempt_tx); start_attempt(slot, pod, selector, &attempt_tx);
if pod == PodId::Minus { if pod == PodId::Plus {
plus_gate = tokio::time::Instant::now() + PLUS_GRACE; plus_gate = tokio::time::Instant::now() + PLUS_GRACE;
} }
status_tx.send_modify(|s| s.get_mut(pod).reset_link(PodState::Searching)); status_tx.send_modify(|s| s.get_mut(pod).reset_link(PodState::Searching));
@@ -679,16 +717,27 @@ async fn run(
// exists to paper over, and it is also the // exists to paper over, and it is also the
// configuration in which the `−` pod stops reporting // configuration in which the `−` pod stops reporting
// its own paddle — the failure that cost an evening. // its own paddle — the failure that cost an evening.
if pod == PodId::Plus if pod == PodId::Minus
&& !take_plus_pod( && !take_minus_pod(
minus.idle(), plus.idle(),
status_tx.borrow().minus.address.is_some(), status_tx.borrow().plus.address.is_some(),
tokio::time::Instant::now() >= plus_gate, tokio::time::Instant::now() >= plus_gate,
) )
{ {
// Once per run of refusals, not once per sighting.
// The device list republishes several times a
// second and every pass re-reports a visible pod,
// so this logged twice a second for as long as the
// + pod was in the room — 274 lines in five minutes
// on the tablet, burying the connect failures we
// were reading the log for.
if !plus_declined {
tracing::debug!( tracing::debug!(
"controller: + pod seen; the − pod speaks for the pair" "controller: − pod seen; the + pod is the controller and \
is up (silenced until this changes)"
); );
plus_declined = true;
}
continue; continue;
} }
let slot = slot_mut(&mut minus, &mut plus, pod); let slot = slot_mut(&mut minus, &mut plus, pod);
@@ -791,8 +840,12 @@ async fn run(
// A `−` pod that is up, or on its way up, is a `−` pod worth // A `−` pod that is up, or on its way up, is a `−` pod worth
// waiting for. Only a slot that has been idle for the whole // waiting for. Only a slot that has been idle for the whole
// grace period lets the `+` pod in. // grace period lets the `+` pod in.
if !minus.idle() { if !plus.idle() {
plus_gate = tokio::time::Instant::now() + PLUS_GRACE; plus_gate = tokio::time::Instant::now() + PLUS_GRACE;
} else {
// The − pod is no longer speaking for the pair, so the next
// refusal — if there is one — is news again.
plus_declined = false;
} }
// Converge on one link. The `+` pod may have connected first — // Converge on one link. The `+` pod may have connected first —
@@ -801,19 +854,20 @@ async fn run(
// and is exactly the configuration that breaks the `−` paddle. // and is exactly the configuration that breaks the `−` paddle.
// Dropping it leaves `auto` alone, so if the `−` pod later goes // Dropping it leaves `auto` alone, so if the `−` pod later goes
// away the `+` pod is picked up again on its next advertisement. // away the `+` pod is picked up again on its next advertisement.
if !minus.idle() && plus.client.is_some() { if !plus.idle() && minus.client.is_some() {
tracing::info!( tracing::info!(
"controller: − pod is up and relays the pair; closing the redundant + link" "controller: + pod is up and is the controller; closing the − link, \
which is the pairing that stops the − pod reporting its own paddle"
); );
forget(PodId::Plus, &mut buttons, &input_tx); forget(PodId::Minus, &mut buttons, &input_tx);
plus.generation += 1; minus.generation += 1;
plus.events = None; minus.events = None;
plus.last_seen = None; minus.last_seen = None;
plus.connected_at = None; minus.connected_at = None;
if let Some(client) = plus.client.take() { if let Some(client) = minus.client.take() {
tokio::spawn(async move { client.shutdown().await }); tokio::spawn(async move { client.shutdown().await });
} }
status_tx.send_modify(|s| s.get_mut(PodId::Plus).reset_link(PodState::Idle)); status_tx.send_modify(|s| s.get_mut(PodId::Minus).reset_link(PodState::Idle));
} }
// A link that is plainly alive and has never carried a button // A link that is plainly alive and has never carried a button
@@ -830,13 +884,27 @@ async fn run(
let slot = slot_mut(&mut minus, &mut plus, id); let slot = slot_mut(&mut minus, &mut plus, id);
let alive = slot.client.is_some() let alive = slot.client.is_some()
&& slot.last_seen.is_some_and(|t| t.elapsed() < STALE_AFTER); && slot.last_seen.is_some_and(|t| t.elapsed() < STALE_AFTER);
let mute = slot.buttons_this_link == 0 // Silence *since the last press*, not "never pressed".
&& slot.connected_at.is_some_and(|t| t.elapsed() > NO_INPUT_AFTER); //
// This used to exempt any link that had ever carried a
// button, on the reasoning that a healthy pod proves itself
// once and should never be disturbed again. The tablet
// disproved it on 2026-08-27: a link carried sixty presses,
// wedged at 15:47:19, and went on streaming battery every
// five seconds while ignoring every press for the rest of
// the ride. Exempt for life meant dead for the ride.
//
// So the clock starts at the last press instead, and falls
// back to the connect for a link that never carried one.
let quiet_since = slot.last_button.or(slot.connected_at);
let mute = quiet_since.is_some_and(|t| t.elapsed() > NO_INPUT_AFTER);
if alive && mute && slot.auto { if alive && mute && slot.auto {
tracing::warn!( tracing::warn!(
pod = id.as_str(), pod = id.as_str(),
"controller: link is alive but has never carried a button; \ presses = slot.buttons_this_link,
recycling it (see REQUIREMENTS §7.1)" "controller: link is alive but has carried no button for \
{}s; recycling it (see REQUIREMENTS §7.1)",
NO_INPUT_AFTER.as_secs()
); );
slot.generation += 1; slot.generation += 1;
slot.connected_at = None; slot.connected_at = None;
@@ -877,23 +945,33 @@ async fn run(
} }
} }
/// May a `+` pod the scan has just seen be connected? /// May a `−` pod the scan has just seen be connected?
/// ///
/// The `−` pod is the controller (§2.3.1): connected on its own it delivers all /// **The `+` pod is the controller.** This is the reverse of what this module
/// ten buttons, its twin's included. So a `+` link is only ever a *substitute*, /// assumed until 2026-08-27, and the measurement that turned it over is in
/// and opening one alongside a working `−` link is the configuration in which /// §2.3.3: the `−` pod stops reporting buttons about fifty seconds into every
/// the `−` pod stops reporting its own paddle. /// session — a status flag flips, and the link stays up and healthy and mute —
/// while the `+` pod ran 133 s with 78 paddle edges, no key offer and no flag,
/// on the same bench, minutes apart.
/// ///
/// Three inputs, in the order they decide: /// The `−` pod does deliver more when it works: all ten buttons, its twin's
/// - `minus_idle` — false when the `−` pod is connected or being connected. /// relayed. But "when it works" is under a minute without the daily Zwift
/// Nothing else matters then: it is already speaking for both. /// blessing, and the `+` pod alone is a complete shifter — its paddle shifts
/// - `minus_known` — we have an address for a `−` pod, from this session or /// up, `Y` shifts down — which is the thing a ride cannot do without.
/// from the remembered-device store. With none, there is no `−` pod to wait ///
/// for and the `+` pod is the whole controller. /// So the `−` pod is now the substitute, and this is the old rule with the
/// - `gate_expired` — the `−` pod has been unreachable for [`PLUS_GRACE`]. /// pods exchanged. Opening both is still the configuration that breaks the `−`
/// A flat or lost `−` pod must not cost the rider their `+` paddle too. /// pod's own paddle, so still only one link.
fn take_plus_pod(minus_idle: bool, minus_known: bool, gate_expired: bool) -> bool { ///
minus_idle && (!minus_known || gate_expired) /// - `plus_idle` — false when the `+` pod is connected or being connected.
/// Nothing else matters then: it is the controller and it is up.
/// - `plus_known` — we have an address for a `+` pod. With none there is
/// nothing to wait for, and the `−` pod is all there is.
/// - `gate_expired` — the `+` pod has been unreachable for [`PLUS_GRACE`].
/// A flat `+` pod must not cost the rider the fifty working seconds the `−`
/// pod still offers.
fn take_minus_pod(plus_idle: bool, plus_known: bool, gate_expired: bool) -> bool {
plus_idle && (!plus_known || gate_expired)
} }
fn slot_mut<'a>(minus: &'a mut Slot, plus: &'a mut Slot, pod: PodId) -> &'a mut Slot { fn slot_mut<'a>(minus: &'a mut Slot, plus: &'a mut Slot, pod: PodId) -> &'a mut Slot {
@@ -971,6 +1049,7 @@ fn apply_attempt(attempt: Attempt, slot: &mut Slot, status_tx: &watch::Sender<Co
// nothing about this one. // nothing about this one.
slot.connected_at = Some(tokio::time::Instant::now()); slot.connected_at = Some(tokio::time::Instant::now());
slot.buttons_this_link = 0; slot.buttons_this_link = 0;
slot.last_button = None;
status_tx.send_modify(|s| { status_tx.send_modify(|s| {
let p = s.get_mut(pod); let p = s.get_mut(pod);
p.state = PodState::Connected; p.state = PodState::Connected;
@@ -1059,6 +1138,7 @@ fn handle_event(
// This link has now proven it carries input, which puts it // This link has now proven it carries input, which puts it
// beyond the no-input watchdog for as long as it lasts. // beyond the no-input watchdog for as long as it lasts.
slot.buttons_this_link = slot.buttons_this_link.saturating_add(1); slot.buttons_this_link = slot.buttons_this_link.saturating_add(1);
slot.last_button = Some(tokio::time::Instant::now());
} }
// A frame is proof the link is up, and it is the *only* proof that // A frame is proof the link is up, and it is the *only* proof that
// ever arrives — the pod does not announce that it has started // ever arrives — the pod does not announce that it has started
@@ -1651,28 +1731,29 @@ mod tests {
} }
#[test] #[test]
fn one_link_is_the_whole_controller() { fn one_link_is_the_whole_controller_and_it_is_the_plus_pod() {
// Confirmed in the field 2026-08-21: pairing the − pod alone gives all // Measured 2026-08-27, both pods on the same bench minutes apart: the −
// ten buttons, because it relays its twin (§2.3.1). So the + pod is a // pod goes mute ~51 s into every session while its link stays up, and
// substitute, never a second half. // the + pod ran 133 s with 78 paddle edges and never flipped a flag
// (§2.3.3). The − pod relays more; the + pod keeps working. Shifting is
// what a ride cannot do without, so the + pod is the controller.
// The − pod is up, or on its way up. Nothing else matters. // The + pod is up, or on its way up. Nothing else matters.
assert!(!take_plus_pod(false, true, true)); assert!(!take_minus_pod(false, true, true));
assert!(!take_plus_pod(false, false, true)); assert!(!take_minus_pod(false, false, true));
// We know a − pod exists and it has not been out of reach for long. It // We know a + pod exists and it has not been out of reach for long. It
// is almost certainly just asleep — a Click only advertises while awake // is almost certainly just asleep — a Click only advertises while awake
// (A-4) — so wait rather than open a link we would only close again. // (A-4) — so wait rather than open a link we would only close again.
assert!(!take_plus_pod(true, true, false)); assert!(!take_minus_pod(true, true, false));
// No − pod has ever been seen or remembered: this rider's + pod *is* // No + pod has ever been seen or remembered: this rider's − pod is all
// their controller, and making them wait for a pod they do not own // there is, fifty working seconds and all.
// would be waiting forever. assert!(take_minus_pod(true, false, false));
assert!(take_plus_pod(true, false, false));
// The − pod is known but has stayed out of reach. Flat, or left in the // The + pod is known but has stayed out of reach. Flat, or left in the
// garage. Half a controller beats none. // garage. Fifty seconds of shifting beats none.
assert!(take_plus_pod(true, true, true)); assert!(take_minus_pod(true, true, true));
} }
#[test] #[test]
+104 -20
View File
@@ -35,11 +35,32 @@ use crate::heart_rate::{HeartRateHandle, HeartRateStatus};
use crate::known::KnownDevices; use crate::known::KnownDevices;
use crate::trainer::{TrainerHandle, TrainerStatus}; use crate::trainer::{TrainerHandle, TrainerStatus};
/// One pass of the scanner. Long enough for a trainer to advertise, short /// How long one discovery session is held before it is recycled.
/// enough that the list feels live. ///
const SCAN_WINDOW: Duration = Duration::from_millis(2500); /// **This is a rate limit, not a dwell time.** Since Android 7 the platform
/// counts an app's scan *starts* and blocks it with
/// `SCAN_FAILED_SCANNING_TOO_FREQUENTLY` after five in any thirty seconds — and
/// it does so silently, so the app keeps asking and simply stops being told
/// about anything. This loop used to open a session every 2.9 s: **ten starts
/// per thirty seconds, double the limit, all by itself**, before the trainer's
/// 15 s search or a pod's 20 s search asked for one too. A rider who woke their
/// pods first spent the pods' search window pushing the count over, and then
/// the trainer could not be found — not because it was not advertising, but
/// because the app was no longer allowed to hear it.
///
/// One session per twenty seconds is three starts a minute with the connect
/// paths included, which leaves headroom under the limit on the worst day.
const SCAN_WINDOW: Duration = Duration::from_secs(20);
/// How often the open session is sampled. The list is republished each time, so
/// this — not [`SCAN_WINDOW`] — is how live the screen feels, and it is now
/// four times quicker than the old whole-pass cadence while starting the radio
/// a seventh as often.
const SCAN_SAMPLE: Duration = Duration::from_millis(700);
/// Poll interval while scanning is switched off. /// Poll interval while scanning is switched off.
const IDLE_POLL: Duration = Duration::from_millis(400); const IDLE_POLL: Duration = Duration::from_millis(400);
/// How often to re-check whether a link has finished being built, while the
/// scanner is holding off for one (see `scan::gatt_setup`).
const GATT_YIELD: Duration = Duration::from_millis(250);
/// How long to wait before looking for the adapter again. Longer than the scan /// How long to wait before looking for the adapter again. Longer than the scan
/// cadence: nothing the rider can do about a missing radio happens in 400 ms. /// cadence: nothing the rider can do about a missing radio happens in 400 ms.
const ADAPTER_RETRY: Duration = Duration::from_secs(2); const ADAPTER_RETRY: Duration = Duration::from_secs(2);
@@ -291,6 +312,7 @@ impl DeviceRegistry {
self.set_scanning(true); self.set_scanning(true);
} }
let next = self.build(&trainer); let next = self.build(&trainer);
let transitions = state_transitions(&self.published, &next); let transitions = state_transitions(&self.published, &next);
let changed = next != self.published; let changed = next != self.published;
@@ -897,30 +919,92 @@ async fn scan_loop(mut on: watch::Receiver<bool>, tx: watch::Sender<ScanSnapshot
// FR-1.1 lists every peripheral, not only fitness machines: a trainer // FR-1.1 lists every peripheral, not only fitness machines: a trainer
// is not obliged to advertise FTMS, and the rider needs to see what is // is not obliged to advertise FTMS, and the rider needs to see what is
// in the room to know the scan is working at all. // in the room to know the scan is working at all.
let result = scan::scan(&adapter, SCAN_WINDOW, ScanKind::All).await; // Wait out any link still being built before opening a session, for the
generation += 1; // same reason the sample loop below yields to one.
let snapshot = match result { while scan::gatt_setup_active() {
Ok(devices) => ScanSnapshot { tokio::time::sleep(GATT_YIELD).await;
devices, }
error: None,
generation, if let Err(e) = scan::begin(&adapter, ScanKind::All, "device list").await {
},
Err(e) => {
// Debug as well as Display, because the useful half of a BLE // Debug as well as Display, because the useful half of a BLE
// failure is usually in the source chain that Display drops. // failure is usually in the source chain that Display drops.
// "bluetooth error: JNI call failed" was the *entire* symptom of // "bluetooth error: JNI call failed" was the *entire* symptom of a
// a detached-thread bug; the Debug form said // detached-thread bug; the Debug form said
// `Bluetooth(Other(JniCall(ThreadDetached)))` and would have // `Bluetooth(Other(JniCall(ThreadDetached)))` and would have named
// named it outright. // it outright.
tracing::warn!(error = %e, cause = ?e, "scan failed"); tracing::warn!(error = %e, cause = ?e, "scan failed to start");
ScanSnapshot { generation += 1;
let _ = tx.send(ScanSnapshot {
devices: Vec::new(), devices: Vec::new(),
error: Some(e.to_string()), error: Some(e.to_string()),
generation, generation,
});
tokio::time::sleep(ADAPTER_RETRY).await;
continue;
}
// Sample the open session until the window is up — or until the scan is
// switched off, which must be honoured *now* rather than at the end of
// the window. A connect suspends this loop precisely so the two do not
// fight over the radio, and a suspension that took twenty seconds to
// land would be no suspension at all.
let window_ends = tokio::time::Instant::now() + SCAN_WINDOW;
let mut outcome = "recycled";
loop {
tokio::select! {
_ = tokio::time::sleep(SCAN_SAMPLE) => {}
changed = on.changed() => {
if changed.is_err() || !*on.borrow() {
outcome = "suspended";
break;
} }
} }
}; }
let _ = tx.send(snapshot); generation += 1;
match scan::peek(&adapter, ScanKind::All).await {
Ok(devices) => {
let _ = tx.send(ScanSnapshot {
devices,
error: None,
generation,
});
}
Err(e) => {
tracing::warn!(error = %e, cause = ?e, "scan sample failed");
let _ = tx.send(ScanSnapshot {
devices: Vec::new(),
error: Some(e.to_string()),
generation,
});
outcome = "failed";
break;
}
}
// Somebody is building a link (`scan::gatt_setup`). Android drops a
// GATT connection that is discovering services while a scan runs —
// on the tablet that was every automatic reconnect failing with
// `Disconnected while discovering services`. So give the radio up
// for the second or two it takes, and pick it up again after.
//
// Only for that window, deliberately. The suspension that covered
// the whole reconnect — search and backoff included — kept this
// loop off the air for as long as the trainer was asleep, which
// starved every other device of discovery: a pod that dropped could
// never be seen again.
if scan::gatt_setup_active() {
outcome = "yielding to a connect";
break;
}
if tokio::time::Instant::now() >= window_ends {
break;
}
}
// Recycled rather than held forever: a peripheral that stops
// advertising stays in the backend's map until the session ends, so
// without this the list would accumulate devices that left the room.
// Twenty seconds is how stale a departed device may look.
scan::end(&adapter, "device list", outcome).await;
tokio::time::sleep(IDLE_POLL).await; tokio::time::sleep(IDLE_POLL).await;
} }
} }
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "BikeControl", "productName": "BikeControl",
"version": "0.2.0", "version": "0.2.6",
"identifier": "paris.tourolle.bikecontrol", "identifier": "paris.tourolle.bikecontrol",
"build": { "build": {
"frontendDist": "../ui/dist", "frontendDist": "../ui/dist",
@@ -43,7 +43,7 @@
], ],
"category": "Utility", "category": "Utility",
"android": { "android": {
"versionCode": 1200 "versionCode": 1206
}, },
"shortDescription": "Indoor cycling trainer control", "shortDescription": "Indoor cycling trainer control",
"longDescription": "Control a smart trainer over BLE, ride gradient profiles and synthetic waveforms, and record the result." "longDescription": "Control a smart trainer over BLE, ride gradient profiles and synthetic waveforms, and record the result."
+41 -27
View File
@@ -35,11 +35,12 @@
* pod stays missing — and the fix belongs next to the symptom. */ * pod stays missing — and the fix belongs next to the symptom. */
const scanning = $derived(app.devices.scanning); const scanning = $derived(app.devices.scanning);
const anyConnected = $derived(pods.some((p) => p.state === 'connected')); const anyConnected = $derived(pods.some((p) => p.state === 'connected'));
/** The pod that speaks for the pair. Connected, this is the whole controller. */ /** The controller. Measured 2026-08-27: the `+` pod keeps reporting where the
const minusLive = $derived(controller?.minus.state === 'connected'); * `−` pod goes mute about fifty seconds in (§2.3.3). */
/** Running on the fallback: the `+` pod alone, with no `−` paddle to shift const plusLive = $derived(controller?.plus.state === 'connected');
* down with beyond its `Y` button. Worth saying out loud. */ /** Running on the fallback: the `−` pod, which relays everything but stops
const plusOnly = $derived(!minusLive && controller?.plus.state === 'connected'); * reporting about fifty seconds in unless Zwift has blessed it today. */
const minusOnly = $derived(!plusLive && controller?.minus.state === 'connected');
const busy = $derived(pods.some((p) => p.state === 'searching')); const busy = $derived(pods.some((p) => p.state === 'searching'));
/** A pod reporting the other's paddle: the pair may be filed the wrong way /** A pod reporting the other's paddle: the pair may be filed the wrong way
* round, and the rider is the only one who can say. */ * round, and the rider is the only one who can say. */
@@ -63,8 +64,8 @@
/** What each pod is for, so a rider who has lost one knows what they lost. */ /** What each pod is for, so a rider who has lost one knows what they lost. */
const PURPOSE: Record<Pod, string> = { const PURPOSE: Record<Pod, string> = {
minus: 'All ten buttons', plus: 'Shift up · A B Y Z',
plus: 'Fallback · shift up, A B Y Z', minus: 'All ten buttons, for ~50 s',
}; };
function connect(pod: Pod) { function connect(pod: Pod) {
@@ -84,11 +85,11 @@
<!-- Nothing can be picked up automatically while the scan is off, so <!-- Nothing can be picked up automatically while the scan is off, so
the way to fix that sits here rather than only in the header. --> the way to fix that sits here rather than only in the header. -->
<button class="btn" onclick={() => app.run(() => api.startScan())}>Start scan</button> <button class="btn" onclick={() => app.run(() => api.startScan())}>Start scan</button>
{:else if !minusLive} {:else if !plusLive}
<!-- The − pod, not both: it is the one that carries the whole <!-- The + pod, not both: joining the pair is what stops the − pod
controller. The + pod has its own button on its own row. --> reporting its own paddle, and the + pod is the one that lasts. -->
<button class="btn" disabled={busy} onclick={() => app.run(() => api.connectController())}> <button class="btn" disabled={busy} onclick={() => app.run(() => api.connectController())}>
{busy ? 'Searching…' : 'Find − pod'} {busy ? 'Searching…' : 'Find + pod'}
</button> </button>
{/if} {/if}
{#if anyConnected} {#if anyConnected}
@@ -103,21 +104,23 @@
the badge above has already said it. --> the badge above has already said it. -->
{#if !scanning} {#if !scanning}
<p class="lede"><strong>The scan is off</strong> — pods will not be picked up.</p> <p class="lede"><strong>The scan is off</strong> — pods will not be picked up.</p>
{:else if plusOnly} {:else if minusOnly}
<p class="lede"> <p class="lede">
Running on the <strong>+ pod alone</strong>: shift down with <span class="kbd">Y</span>. Running on the <strong>− pod</strong>, which relays all ten buttons but stops reporting
Press a button on the − pod for the D-pad. after about a minute unless Zwift has blessed it today. Press a button on the
<strong>+ pod</strong> for shifting that lasts.
</p> </p>
{:else if !minusLive} {:else if !plusLive}
<p class="lede"> <p class="lede">
<strong>Press any button on the − pod.</strong> It only advertises while awake; the running <strong>Press any button on the + pod.</strong> It only advertises while awake; the running
scan connects it as soon as it does. scan connects it as soon as it does. Its paddle shifts up and
<span class="kbd">Y</span> shifts down.
</p> </p>
{/if} {/if}
<div class="pods"> <div class="pods">
{#each pods as pod (pod.pod)} {#each pods as pod (pod.pod)}
{@const dormant = pod.pod === 'plus' && minusLive && pod.state !== 'connected'} {@const dormant = pod.pod === 'minus' && plusLive && pod.state !== 'connected'}
<article class="pod" class:live={pod.state === 'connected'}> <article class="pod" class:live={pod.state === 'connected'}>
<!-- The paddle glyph is the pod's identity — big enough to match <!-- The paddle glyph is the pod's identity — big enough to match
against the one printed on the hardware at arm's length. --> against the one printed on the hardware at arm's length. -->
@@ -128,8 +131,8 @@
<span class="purpose"> <span class="purpose">
{#if dormant} {#if dormant}
<!-- Not a fault, and the panel must not let it read as one: this <!-- Not a fault, and the panel must not let it read as one: this
pod is idle because the − pod is already sending its buttons. --> pod is idle because joining the pair is what breaks it. -->
Relayed by the − pod Held back — the + pod is the controller
{:else if pod.confirmed} {:else if pod.confirmed}
Confirmed — sent its own {pod.symbol} paddle Confirmed — sent its own {pod.symbol} paddle
{:else if pod.state === 'connected'} {:else if pod.state === 'connected'}
@@ -158,10 +161,15 @@
<button class="btn ghost" onclick={() => disconnect(pod.pod)}>Disconnect</button> <button class="btn ghost" onclick={() => disconnect(pod.pod)}>Disconnect</button>
{:else if pod.state === 'searching'} {:else if pod.state === 'searching'}
<button class="btn ghost" onclick={() => disconnect(pod.pod)}>Stop</button> <button class="btn ghost" onclick={() => disconnect(pod.pod)}>Stop</button>
{:else if dormant}
<!-- No "connect anyway". Joining the pair is the one action that
stops the − pod reporting its own paddle (§2.3.1), and offering
it beside a working controller put the failure one tap from a
rider hunting for a fix. Rust refuses it too. The fallback is
not lost: the moment the − pod goes, this pod is taken. -->
<span class="held">Held in reserve</span>
{:else} {:else}
<button class="btn ghost" onclick={() => connect(pod.pod)}> <button class="btn ghost" onclick={() => connect(pod.pod)}>Find it</button>
{dormant ? 'Connect anyway' : 'Find it'}
</button>
{/if} {/if}
</div> </div>
@@ -188,7 +196,7 @@
</div> </div>
{/if} {/if}
{#if !minusLive} {#if !plusLive}
<!-- <!--
FR-1.8. "Not connected" on its own reads as a broken app, and the real FR-1.8. "Not connected" on its own reads as a broken app, and the real
cause — a pod that is simply asleep — is something only the rider can fix. cause — a pod that is simply asleep — is something only the rider can fix.
@@ -199,13 +207,13 @@
<details class="help"> <details class="help">
<summary>Still not connecting?</summary> <summary>Still not connecting?</summary>
<ul> <ul>
<li><strong>Press a button.</strong> A pod that is asleep does not advertise at all.</li> <li><strong>Press a button on the + pod.</strong> An asleep pod does not advertise.</li>
<li><strong>Keep the scan on.</strong> Auto-connect runs off it.</li> <li><strong>Keep the scan on.</strong> Auto-connect runs off it.</li>
<li><strong>Close Zwift.</strong> One app at a time holds a pod.</li> <li><strong>Close Zwift.</strong> One app at a time holds a pod.</li>
<li><strong>Charge it.</strong> A flat pod stops advertising, and the app stops chasing.</li> <li><strong>Charge it.</strong> A flat pod stops advertising, and the app stops chasing.</li>
<li> <li>
<strong>Or use the + pod</strong> — you lose the D-pad, <span class="kbd">Y</span> still <strong>Or use the − pod</strong> — it relays all ten buttons, but goes quiet after
shifts down. about a minute unless Zwift has blessed it in the last day.
</li> </li>
<li> <li>
The keyboard mirrors every Click action; <span class="kbd">?</span> lists them. A ride The keyboard mirrors every Click action; <span class="kbd">?</span> lists them. A ride
@@ -353,6 +361,12 @@
font-size: 0.82rem; font-size: 0.82rem;
} }
.held {
align-self: center;
font-size: 0.8rem;
color: var(--ink-faint);
}
.note { .note {
flex: 1 1 100%; flex: 1 1 100%;
margin: 0; margin: 0;
+18 -6
View File
@@ -19,7 +19,7 @@
* strangers fold away into a collapsed list at the bottom. * strangers fold away into a collapsed list at the bottom.
*/ */
import { app } from '../lib/app.svelte'; import { app } from '../lib/app.svelte';
import { api } from '../lib/bridge'; import { api, type Pod } from '../lib/bridge';
import { connectionText, rssiBars } from '../lib/format'; import { connectionText, rssiBars } from '../lib/format';
import type { DeviceInfo, DeviceKind } from '../lib/types'; import type { DeviceInfo, DeviceKind } from '../lib/types';
import ClickPanel from './ClickPanel.svelte'; import ClickPanel from './ClickPanel.svelte';
@@ -116,6 +116,12 @@
* were a separate button the rider had failed to press would be a lie about * were a separate button the rider had failed to press would be a lie about
* what the protocol does. * what the protocol does.
*/ */
/** Drop a pod's link and take it again — the fix for a wedged session. */
async function reconnectPod(pod: Pod) {
await api.disconnectController(pod);
await api.connectController(pod);
}
async function reacquire(id: string) { async function reacquire(id: string) {
await api.disconnect(id); await api.disconnect(id);
await api.connect(id); await api.connect(id);
@@ -160,7 +166,8 @@
const minus = app.controller?.minus; const minus = app.controller?.minus;
const plus = app.controller?.plus; const plus = app.controller?.plus;
const live = minus?.state === 'connected' ? minus : plus?.state === 'connected' ? plus : null; // The `+` pod first: it is the one that keeps reporting (§2.3.3).
const live = plus?.state === 'connected' ? plus : minus?.state === 'connected' ? minus : null;
const searching = minus?.state === 'searching' || plus?.state === 'searching'; const searching = minus?.state === 'searching' || plus?.state === 'searching';
out.push({ out.push({
key: 'click', key: 'click',
@@ -171,16 +178,21 @@
primary: live ? `Zwift Click · ${live.symbol} pod` : searching ? 'Searching…' : 'Not connected', primary: live ? `Zwift Click · ${live.symbol} pod` : searching ? 'Searching…' : 'Not connected',
detail: live detail: live
? [ ? [
live.pod === 'minus' ? 'All ten buttons' : 'Fallback — no D-pad', live.pod === 'plus' ? 'Shift up · Y shifts down' : 'All ten buttons, for ~50 s',
live.batteryPercent != null ? `${live.batteryPercent}%` : null, live.batteryPercent != null ? `${live.batteryPercent}%` : null,
live.buttonsSeen > 0 ? `${live.buttonsSeen} presses` : 'no presses yet', live.buttonsSeen > 0 ? `${live.buttonsSeen} presses` : 'no presses yet',
] ]
.filter(Boolean) .filter(Boolean)
.join(' · ') .join(' · ')
: 'Press a button to wake it — the keyboard works meanwhile', : 'Press a button on the + pod — the keyboard works meanwhile',
tone: live ? 'tone-ok' : 'tone-idle', tone: live ? 'tone-ok' : 'tone-idle',
// Connected, the useful action is *rebuild the link*. A Click can wedge
// while looking perfectly healthy — battery arriving every five seconds,
// every press ignored (§7.1) — and the supervisor's own recovery is
// deliberately slow, because it cannot tell a wedged pod from a rider who
// simply is not shifting. The rider can, instantly.
action: live action: live
? null ? { label: 'Reconnect', run: () => app.run(() => reconnectPod(live.pod)) }
: { label: 'Find it', run: () => app.run(() => api.connectController()) }, : { label: 'Find it', run: () => app.run(() => api.connectController()) },
secondary: live secondary: live
? { label: 'Disconnect', run: () => app.run(() => api.disconnectController()) } ? { label: 'Disconnect', run: () => app.run(() => api.disconnectController()) }
@@ -218,7 +230,7 @@
/** The Click panel is a repair manual, so it appears when there is something /** The Click panel is a repair manual, so it appears when there is something
* to repair. Connected, the tile above has already said everything it knows. */ * to repair. Connected, the tile above has already said everything it knows. */
const clickNeedsHelp = $derived(app.controller?.minus.state !== 'connected'); const clickNeedsHelp = $derived(app.controller?.plus.state !== 'connected');
/** /**
* What the row has to say, as badges. Nothing is emitted for a device that is * What the row has to say, as badges. Nothing is emitted for a device that is