Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89920e782d | ||
|
|
8964a0fd74 | ||
|
|
e8d384e6fc | ||
|
|
c1c6ca390c | ||
|
|
b1e6c08d07 | ||
|
|
34861e4e04 | ||
|
|
9ab5b5530b | ||
|
|
4e400cdd3b | ||
|
|
135da73e92 | ||
|
|
92ce4ba2ae | ||
|
|
53e7cd05fc | ||
|
|
bbd11757ee | ||
|
|
9b1749d959 | ||
|
|
e589086078 |
Generated
+149
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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)),
|
||||||
|
|||||||
@@ -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})"))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user